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="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="[
|
:class="[
|
||||||
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||||
isSidebarCollapsed ? 'w-16' : 'w-72',
|
isSidebarCollapsed ? 'w-16' : 'w-80',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -215,6 +215,14 @@ export default {
|
|||||||
type: "navigation",
|
type: "navigation",
|
||||||
route: { name: "rnpath" },
|
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",
|
id: "nav-translator",
|
||||||
title: "nav_translator",
|
title: "nav_translator",
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export default {
|
|||||||
{ code: "en", name: "English" },
|
{ code: "en", name: "English" },
|
||||||
{ code: "de", name: "Deutsch" },
|
{ code: "de", name: "Deutsch" },
|
||||||
{ code: "ru", name: "Русский" },
|
{ code: "ru", name: "Русский" },
|
||||||
|
{ code: "it", name: "Italiano" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,6 +24,11 @@
|
|||||||
icon-name="alert"
|
icon-name="alert"
|
||||||
class="h-6 w-6 text-amber-500"
|
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" />
|
<MaterialDesignIcon v-else icon-name="information" class="h-6 w-6 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,24 +75,58 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
add(toast) {
|
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 id = this.counter++;
|
||||||
const newToast = {
|
const newToast = {
|
||||||
id,
|
id,
|
||||||
|
key: toast.key,
|
||||||
message: toast.message,
|
message: toast.message,
|
||||||
type: toast.type || "info",
|
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) {
|
if (newToast.duration > 0) {
|
||||||
setTimeout(() => {
|
newToast.timer = setTimeout(() => {
|
||||||
this.remove(id);
|
this.remove(id);
|
||||||
}, newToast.duration);
|
}, newToast.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.toasts.push(newToast);
|
||||||
},
|
},
|
||||||
remove(id) {
|
remove(id) {
|
||||||
const index = this.toasts.findIndex((t) => t.id === id);
|
const index = this.toasts.findIndex((t) => t.id === id);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
|
const toast = this.toasts[index];
|
||||||
|
if (toast.timer) {
|
||||||
|
clearTimeout(toast.timer);
|
||||||
|
}
|
||||||
this.toasts.splice(index, 1);
|
this.toasts.splice(index, 1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -402,7 +402,7 @@
|
|||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
appInfo.is_connected_to_shared_instance
|
appInfo.is_connected_to_shared_instance
|
||||||
? "Shared Instance"
|
? `Shared Instance: ${appInfo.shared_instance_address || "unknown"}`
|
||||||
: "Main Instance"
|
: "Main Instance"
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
@@ -659,6 +659,14 @@
|
|||||||
<div
|
<div
|
||||||
class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="secondary-chip !px-3 !py-1 !text-[10px]"
|
class="secondary-chip !px-3 !py-1 !text-[10px]"
|
||||||
@@ -742,6 +750,14 @@
|
|||||||
<div
|
<div
|
||||||
class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="secondary-chip !px-3 !py-1 !text-[10px]"
|
class="secondary-chip !px-3 !py-1 !text-[10px]"
|
||||||
@@ -1001,6 +1017,42 @@ export default {
|
|||||||
console.log("Failed to list auto-backups");
|
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) {
|
async deleteSnapshot(filename) {
|
||||||
if (!(await DialogUtils.confirm(this.$t("about.delete_snapshot_confirm")))) return;
|
if (!(await DialogUtils.confirm(this.$t("about.delete_snapshot_confirm")))) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -26,20 +26,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass-card space-y-4">
|
<div class="glass-card flex flex-col md:flex-row md:items-center justify-between gap-6 p-6 md:p-8">
|
||||||
<div class="flex flex-wrap gap-3 items-center">
|
<div class="space-y-3 flex-1 min-w-0">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
{{ $t("interfaces.manage") }}
|
||||||
{{ $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>
|
</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">
|
<RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
|
||||||
<MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
|
<MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
|
||||||
{{ $t("interfaces.add_interface") }}
|
{{ $t("interfaces.add_interface") }}
|
||||||
@@ -52,56 +50,34 @@
|
|||||||
<MaterialDesignIcon icon-name="export" class="w-4 h-4" />
|
<MaterialDesignIcon icon-name="export" class="w-4 h-4" />
|
||||||
{{ $t("interfaces.export_all") }}
|
{{ $t("interfaces.export_all") }}
|
||||||
</button>
|
</button>
|
||||||
<!--
|
</div>
|
||||||
<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
|
<input
|
||||||
v-model="searchTerm"
|
v-model="searchTerm"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="$t('interfaces.search_placeholder')"
|
: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
|
<button
|
||||||
type="button"
|
v-if="searchTerm"
|
||||||
:class="filterChipClass(statusFilter === 'all')"
|
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="setStatusFilter('all')"
|
@click="searchTerm = ''"
|
||||||
>
|
>
|
||||||
{{ $t("interfaces.all") }}
|
<MaterialDesignIcon icon-name="close-circle" class="size-5" />
|
||||||
</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") }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full sm:w-60">
|
<div>
|
||||||
<select v-model="typeFilter" class="input-field">
|
<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 value="all">{{ $t("interfaces.all_types") }}</option>
|
||||||
<option v-for="type in sortedInterfaceTypes" :key="type" :value="type">
|
<option v-for="type in sortedInterfaceTypes" :key="type" :value="type">
|
||||||
{{ type }}
|
{{ type }}
|
||||||
@@ -127,10 +103,47 @@
|
|||||||
|
|
||||||
<div v-if="activeTab === 'overview'" class="space-y-4">
|
<div v-if="activeTab === 'overview'" class="space-y-4">
|
||||||
<div class="glass-card space-y-3">
|
<div class="glass-card space-y-3">
|
||||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
Configured
|
<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>
|
||||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="filteredInterfaces.length !== 0"
|
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"
|
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>
|
||||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
Recently Heard Announces
|
Recently Heard Announces
|
||||||
|
<span
|
||||||
|
v-if="sortedDiscoveredInterfaces.length > 0"
|
||||||
|
class="ml-2 text-sm font-medium text-gray-400"
|
||||||
|
>({{ sortedDiscoveredInterfaces.length }})</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
Discovery runs continually; heard announces stay listed. Connected entries show
|
Discovery runs continually; heard announces stay listed. Connected entries show
|
||||||
a green pill; disconnected entries are dimmed with a red label.
|
a green pill; disconnected entries are dimmed with a red label.
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
v-if="interfacesWithLocation.length > 0"
|
v-if="interfacesWithLocation.length > 0"
|
||||||
type="button"
|
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"
|
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
|
<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" />
|
<MaterialDesignIcon icon-name="lan-disconnect" class="w-3.5 h-3.5" />
|
||||||
<span>{{ $t("app.disabled") }}</span>
|
<span>{{ $t("app.disabled") }}</span>
|
||||||
@@ -522,6 +559,7 @@ export default {
|
|||||||
savingDiscovery: false,
|
savingDiscovery: false,
|
||||||
discoveredInterfaces: [],
|
discoveredInterfaces: [],
|
||||||
discoveredActive: [],
|
discoveredActive: [],
|
||||||
|
discoveredStatusFilter: "all",
|
||||||
discoveryInterval: null,
|
discoveryInterval: null,
|
||||||
activeTab: "overview",
|
activeTab: "overview",
|
||||||
};
|
};
|
||||||
@@ -596,7 +634,31 @@ export default {
|
|||||||
return Array.from(types).sort();
|
return Array.from(types).sort();
|
||||||
},
|
},
|
||||||
sortedDiscoveredInterfaces() {
|
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() {
|
interfacesWithLocation() {
|
||||||
return this.discoveredInterfaces.filter((iface) => iface.latitude != null && iface.longitude != null);
|
return this.discoveredInterfaces.filter((iface) => iface.latitude != null && iface.longitude != null);
|
||||||
@@ -806,6 +868,7 @@ export default {
|
|||||||
...iface,
|
...iface,
|
||||||
last_heard: lastHeard,
|
last_heard: lastHeard,
|
||||||
__isNew: isNew || existing?.__isNew,
|
__isNew: isNew || existing?.__isNew,
|
||||||
|
disconnected_at: existing?.disconnected_at ?? null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -814,6 +877,18 @@ export default {
|
|||||||
|
|
||||||
this.discoveredInterfaces = Array.from(merged.values());
|
this.discoveredInterfaces = Array.from(merged.values());
|
||||||
this.discoveredActive = active;
|
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) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
@@ -851,6 +926,13 @@ export default {
|
|||||||
return hostMatch && portMatch && (s.connected || s.online);
|
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) {
|
goToMap(iface) {
|
||||||
if (iface.latitude == null || iface.longitude == null) return;
|
if (iface.latitude == null || iface.longitude == null) return;
|
||||||
this.$router.push({
|
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>
|
<DropDownMenu>
|
||||||
<template #button>
|
<template #button>
|
||||||
<IconButton>
|
<IconButton>
|
||||||
<MaterialDesignIcon icon-name="dots-vertical" class="size-6" />
|
<MaterialDesignIcon icon-name="dots-vertical" class="size-7" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</template>
|
</template>
|
||||||
<template #items>
|
<template #items>
|
||||||
@@ -43,6 +43,22 @@
|
|||||||
<span class="text-red-500">Delete Message History</span>
|
<span class="text-red-500">Delete Message History</span>
|
||||||
</DropDownMenuItem>
|
</DropDownMenuItem>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
</DropDownMenu>
|
</DropDownMenu>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,7 +86,18 @@ export default {
|
|||||||
required: true,
|
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: {
|
computed: {
|
||||||
isBlocked() {
|
isBlocked() {
|
||||||
if (!this.peer) {
|
if (!this.peer) {
|
||||||
@@ -79,7 +106,72 @@ export default {
|
|||||||
return GlobalState.blockedDestinations.some((b) => b.destination_hash === this.peer.destination_hash);
|
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: {
|
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() {
|
async onBlockDestination() {
|
||||||
if (
|
if (
|
||||||
!(await DialogUtils.confirm(
|
!(await DialogUtils.confirm(
|
||||||
|
|||||||
@@ -57,24 +57,32 @@
|
|||||||
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
|
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- destination hash -->
|
||||||
<div class="inline-block mr-1">
|
<div
|
||||||
<div
|
class="cursor-pointer hover:text-blue-500 transition-colors truncate max-w-[120px] sm:max-w-none shrink-0"
|
||||||
class="cursor-pointer hover:text-blue-500 transition-colors"
|
:title="selectedPeer.destination_hash"
|
||||||
:title="selectedPeer.destination_hash"
|
@click="copyHash(selectedPeer.destination_hash)"
|
||||||
@click="copyHash(selectedPeer.destination_hash)"
|
>
|
||||||
>
|
{{ formatDestinationHash(selectedPeer.destination_hash) }}
|
||||||
{{ formatDestinationHash(selectedPeer.destination_hash) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inline-block">
|
<div
|
||||||
<div class="flex space-x-1">
|
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 -->
|
<!-- hops away -->
|
||||||
<span
|
<span
|
||||||
v-if="selectedPeerPath"
|
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)"
|
@click="onDestinationPathClick(selectedPeerPath)"
|
||||||
>
|
>
|
||||||
<span v-if="selectedPeerPath.hops === 0 || selectedPeerPath.hops === 1">{{
|
<span v-if="selectedPeerPath.hops === 0 || selectedPeerPath.hops === 1">{{
|
||||||
@@ -84,19 +92,30 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- snr -->
|
<!-- snr -->
|
||||||
<span v-if="selectedPeerSignalMetrics?.snr != null" class="flex my-auto space-x-1">
|
<span
|
||||||
<span v-if="selectedPeerPath">•</span>
|
v-if="selectedPeerSignalMetrics?.snr != null"
|
||||||
<span class="cursor-pointer" @click="onSignalMetricsClick(selectedPeerSignalMetrics)">{{
|
class="flex items-center gap-2 shrink-0"
|
||||||
$t("messages.snr", { snr: selectedPeerSignalMetrics.snr })
|
>
|
||||||
}}</span>
|
<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>
|
</span>
|
||||||
|
|
||||||
<!-- stamp cost -->
|
<!-- stamp cost -->
|
||||||
<span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex my-auto space-x-1">
|
<span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex items-center gap-2 shrink-0">
|
||||||
<span v-if="selectedPeerPath || selectedPeerSignalMetrics?.snr != null">•</span>
|
<span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
|
||||||
<span class="cursor-pointer" @click="onStampInfoClick(selectedPeerLxmfStampInfo)">{{
|
<span
|
||||||
$t("messages.stamp_cost", { cost: selectedPeerLxmfStampInfo.stamp_cost })
|
class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
|
||||||
}}</span>
|
title="LXMF stamp requirement"
|
||||||
|
@click="onStampInfoClick(selectedPeerLxmfStampInfo)"
|
||||||
|
>{{
|
||||||
|
$t("messages.stamp_cost", { cost: selectedPeerLxmfStampInfo.stamp_cost })
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,7 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- dropdown menu -->
|
<!-- 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 -->
|
<!-- retry all failed messages -->
|
||||||
<IconButton
|
<IconButton
|
||||||
v-if="hasFailedOrCancelledMessages"
|
v-if="hasFailedOrCancelledMessages"
|
||||||
@@ -112,7 +131,22 @@
|
|||||||
class="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
|
class="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
@click="retryAllFailedOrCancelledMessages"
|
@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>
|
</IconButton>
|
||||||
|
|
||||||
<ConversationDropDownMenu
|
<ConversationDropDownMenu
|
||||||
@@ -123,23 +157,151 @@
|
|||||||
@popout="openConversationPopout"
|
@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 -->
|
<!-- close button -->
|
||||||
<IconButton title="Close" @click="close">
|
<IconButton title="Close" @click="close">
|
||||||
<MaterialDesignIcon icon-name="close" class="size-6" />
|
<MaterialDesignIcon icon-name="close" class="size-7" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Share Contact Modal -->
|
||||||
<div
|
<div
|
||||||
v-if="isShareContactModalOpen"
|
v-if="isShareContactModalOpen"
|
||||||
@@ -264,15 +426,54 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- content -->
|
<!-- content -->
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<div
|
<div
|
||||||
v-if="chatItem.lxmf_message.content && !getParsedItems(chatItem)?.isOnlyPaperMessage"
|
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="{
|
:style="{
|
||||||
'font-family': 'inherit',
|
'font-family': 'inherit',
|
||||||
'font-size': (config?.message_font_size || 14) + 'px',
|
'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>
|
</div>
|
||||||
|
|
||||||
<!-- parsed items (contacts / paper messages) -->
|
<!-- parsed items (contacts / paper messages) -->
|
||||||
@@ -481,29 +682,120 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- telemetry / location field -->
|
<!-- commands -->
|
||||||
<div v-if="chatItem.lxmf_message.fields?.telemetry?.location" class="pb-1 mt-1">
|
<div v-if="chatItem.lxmf_message.fields?.commands" class="space-y-2 mt-1">
|
||||||
<button
|
<div v-for="(command, index) in chatItem.lxmf_message.fields.commands" :key="index">
|
||||||
type="button"
|
<div
|
||||||
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"
|
v-if="command['0x01'] || command['1'] || command['0x1']"
|
||||||
:class="
|
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"
|
||||||
chatItem.is_outbound
|
:class="
|
||||||
? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
|
chatItem.is_outbound
|
||||||
: 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
|
? '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" />
|
<MaterialDesignIcon icon-name="crosshairs-question" class="size-5" />
|
||||||
<div class="text-left">
|
<div class="text-left">
|
||||||
<div class="font-bold text-xs uppercase tracking-wider opacity-80">
|
<div class="font-bold text-xs uppercase tracking-wider opacity-80">
|
||||||
Location
|
{{ $t("messages.location_requested") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] font-mono opacity-70">
|
<div v-if="!chatItem.is_outbound" class="text-[10px] opacity-70">
|
||||||
{{ chatItem.lxmf_message.fields.telemetry.location.latitude.toFixed(6) }},
|
Peer is requesting your location
|
||||||
{{ chatItem.lxmf_message.fields.telemetry.location.longitude.toFixed(6) }}
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1395,6 +1687,7 @@ import GlobalEmitter from "../../js/GlobalEmitter";
|
|||||||
import ToastUtils from "../../js/ToastUtils";
|
import ToastUtils from "../../js/ToastUtils";
|
||||||
import PaperMessageModal from "./PaperMessageModal.vue";
|
import PaperMessageModal from "./PaperMessageModal.vue";
|
||||||
import GlobalState from "../../js/GlobalState";
|
import GlobalState from "../../js/GlobalState";
|
||||||
|
import MarkdownRenderer from "../../js/MarkdownRenderer";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ConversationViewer",
|
name: "ConversationViewer",
|
||||||
@@ -1428,7 +1721,7 @@ export default {
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emits: ["close", "reload-conversations", "update:selectedPeer"],
|
emits: ["close", "reload-conversations", "update:selectedPeer", "update-peer-tracking"],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
GlobalState,
|
GlobalState,
|
||||||
@@ -1493,6 +1786,9 @@ export default {
|
|||||||
translatorLanguages: [],
|
translatorLanguages: [],
|
||||||
propagationNodeStatus: null,
|
propagationNodeStatus: null,
|
||||||
propagationStatusInterval: null,
|
propagationStatusInterval: null,
|
||||||
|
|
||||||
|
showTelemetryInChat: false,
|
||||||
|
isTelemetryHistoryModalOpen: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -1605,7 +1901,15 @@ export default {
|
|||||||
chatItem.lxmf_message.source_hash === this.selectedPeer.destination_hash;
|
chatItem.lxmf_message.source_hash === this.selectedPeer.destination_hash;
|
||||||
const isToSelectedPeer =
|
const isToSelectedPeer =
|
||||||
chatItem.lxmf_message.destination_hash === this.selectedPeer.destination_hash;
|
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;
|
return false;
|
||||||
@@ -1615,6 +1919,24 @@ export default {
|
|||||||
// no peer, so no chat items!
|
// no peer, so no chat items!
|
||||||
return [];
|
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() {
|
selectedPeerChatItemsReversed() {
|
||||||
// ensure a copy of the array is returned in reverse order
|
// ensure a copy of the array is returned in reverse order
|
||||||
return this.selectedPeerChatItems.map((message) => message).reverse();
|
return this.selectedPeerChatItems.map((message) => message).reverse();
|
||||||
@@ -1631,6 +1953,31 @@ export default {
|
|||||||
(item) => item.is_outbound && ["failed", "cancelled"].includes(item.lxmf_message?.state)
|
(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: {
|
watch: {
|
||||||
selectedPeer: {
|
selectedPeer: {
|
||||||
@@ -1695,6 +2042,26 @@ export default {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
async updatePropagationNodeStatus() {
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.get("/api/v1/lxmf/propagation-node/status");
|
const response = await window.axios.get("/api/v1/lxmf/propagation-node/status");
|
||||||
@@ -2048,13 +2415,7 @@ export default {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "lxm.ingest_uri.result": {
|
case "lxm.ingest_uri.result": {
|
||||||
if (json.status === "success") {
|
// Handled in App.vue or MessagesPage.vue
|
||||||
ToastUtils.success(json.message);
|
|
||||||
} else if (json.status === "error") {
|
|
||||||
ToastUtils.error(json.message);
|
|
||||||
} else {
|
|
||||||
ToastUtils.warning(json.message);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2661,6 +3022,7 @@ export default {
|
|||||||
fileAttachmentsTotalSize += file.size;
|
fileAttachmentsTotalSize += file.size;
|
||||||
fileAttachments.push({
|
fileAttachments.push({
|
||||||
file_name: file.name,
|
file_name: file.name,
|
||||||
|
file_size: file.size,
|
||||||
file_bytes: Utils.arrayBufferToBase64(await file.arrayBuffer()),
|
file_bytes: Utils.arrayBufferToBase64(await file.arrayBuffer()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2675,6 +3037,7 @@ export default {
|
|||||||
imageTotalSize += image.size;
|
imageTotalSize += image.size;
|
||||||
images.push({
|
images.push({
|
||||||
image_type: image.type.replace("image/", ""),
|
image_type: image.type.replace("image/", ""),
|
||||||
|
image_size: image.size,
|
||||||
image_bytes: Utils.arrayBufferToBase64(await image.arrayBuffer()),
|
image_bytes: Utils.arrayBufferToBase64(await image.arrayBuffer()),
|
||||||
name: image.name,
|
name: image.name,
|
||||||
});
|
});
|
||||||
@@ -2687,6 +3050,7 @@ export default {
|
|||||||
audioTotalSize = this.newMessageAudio.size;
|
audioTotalSize = this.newMessageAudio.size;
|
||||||
fields["audio"] = {
|
fields["audio"] = {
|
||||||
audio_mode: this.newMessageAudio.audio_mode,
|
audio_mode: this.newMessageAudio.audio_mode,
|
||||||
|
audio_size: this.newMessageAudio.size,
|
||||||
audio_bytes: Utils.arrayBufferToBase64(await this.newMessageAudio.audio_blob.arrayBuffer()),
|
audio_bytes: Utils.arrayBufferToBase64(await this.newMessageAudio.audio_blob.arrayBuffer()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2882,27 +3246,59 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async shareLocation() {
|
async shareLocation() {
|
||||||
|
const toastKey = "location_share";
|
||||||
try {
|
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) {
|
if (!navigator.geolocation) {
|
||||||
DialogUtils.alert(this.$t("map.geolocation_not_supported"));
|
DialogUtils.alert(this.$t("map.geolocation_not_supported"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ToastUtils.loading(this.$t("messages.fetching_location"), 0, toastKey);
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => {
|
(position) => {
|
||||||
this.newMessageTelemetry = {
|
this.newMessageTelemetry = {
|
||||||
latitude: position.coords.latitude,
|
latitude: position.coords.latitude,
|
||||||
longitude: position.coords.longitude,
|
longitude: position.coords.longitude,
|
||||||
altitude: position.coords.altitude || 0,
|
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,
|
bearing: position.coords.heading || 0,
|
||||||
accuracy: position.coords.accuracy || 0,
|
accuracy: position.coords.accuracy || 0,
|
||||||
last_update: Math.floor(Date.now() / 1000),
|
last_update: Math.floor(Date.now() / 1000),
|
||||||
};
|
};
|
||||||
this.sendMessage();
|
this.sendMessage();
|
||||||
|
ToastUtils.success(this.$t("messages.location_sent"), 3000, toastKey);
|
||||||
},
|
},
|
||||||
(error) => {
|
(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,
|
enableHighAccuracy: true,
|
||||||
@@ -2912,6 +3308,7 @@ export default {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
|
ToastUtils.error(`Error: ${e.message}`, 5000, toastKey);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async requestLocation() {
|
async requestLocation() {
|
||||||
@@ -2924,9 +3321,7 @@ export default {
|
|||||||
destination_hash: this.selectedPeer.destination_hash,
|
destination_hash: this.selectedPeer.destination_hash,
|
||||||
content: "",
|
content: "",
|
||||||
fields: {
|
fields: {
|
||||||
commands: [
|
commands: [{ "0x01": Math.floor(Date.now() / 1000) }],
|
||||||
{ "0x01": Math.floor(Date.now() / 1000) }, // Sideband TELEMETRY_REQUEST
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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) {
|
formatTimeAgo: function (datetimeString) {
|
||||||
return Utils.formatTimeAgo(datetimeString);
|
return Utils.formatTimeAgo(datetimeString);
|
||||||
},
|
},
|
||||||
@@ -3387,4 +3808,29 @@ export default {
|
|||||||
.dark .audio-controls-dark {
|
.dark .audio-controls-dark {
|
||||||
filter: invert(1) hue-rotate(180deg);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
:selected-peer="selectedPeer"
|
:selected-peer="selectedPeer"
|
||||||
:conversations="conversations"
|
:conversations="conversations"
|
||||||
@update:selected-peer="onPeerClick"
|
@update:selected-peer="onPeerClick"
|
||||||
|
@update-peer-tracking="onUpdatePeerTracking"
|
||||||
@close="onCloseConversationViewer"
|
@close="onCloseConversationViewer"
|
||||||
@reload-conversations="getConversations"
|
@reload-conversations="getConversations"
|
||||||
/>
|
/>
|
||||||
@@ -293,16 +294,21 @@ export default {
|
|||||||
await this.getConversations();
|
await this.getConversations();
|
||||||
break;
|
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": {
|
case "lxm.ingest_uri.result": {
|
||||||
if (json.status === "success") {
|
if (json.status === "success") {
|
||||||
ToastUtils.success(json.message);
|
this.ingestUri = "";
|
||||||
await this.getConversations();
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -398,6 +404,7 @@ export default {
|
|||||||
contact_image: conversation.contact_image ?? existingPeer.contact_image,
|
contact_image: conversation.contact_image ?? existingPeer.contact_image,
|
||||||
lxmf_user_icon: conversation.lxmf_user_icon ?? existingPeer.lxmf_user_icon,
|
lxmf_user_icon: conversation.lxmf_user_icon ?? existingPeer.lxmf_user_icon,
|
||||||
updated_at: conversation.updated_at ?? existingPeer.updated_at,
|
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] || {};
|
const existing = this.peers[announce.destination_hash] || {};
|
||||||
this.peers[announce.destination_hash] = { ...existing, ...announce };
|
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) {
|
onPeerClick: function (peer) {
|
||||||
// update selected peer
|
// update selected peer
|
||||||
this.selectedPeer = peer;
|
this.selectedPeer = peer;
|
||||||
|
|||||||
@@ -390,27 +390,37 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600 dark:text-gray-400 text-xs mt-0.5 truncate">
|
<div class="text-gray-600 dark:text-gray-400 text-xs mt-0.5 truncate">
|
||||||
{{
|
{{
|
||||||
conversation.latest_message_preview ??
|
stripMarkdown(
|
||||||
conversation.latest_message_title ??
|
conversation.latest_message_preview ?? conversation.latest_message_title
|
||||||
"No messages yet"
|
) ?? "No messages yet"
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-1">
|
<div class="flex flex-col items-center justify-between ml-1 py-1 shrink-0">
|
||||||
<div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
|
<div class="flex items-center space-x-1">
|
||||||
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4" />
|
<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>
|
||||||
<div
|
<button
|
||||||
v-if="
|
type="button"
|
||||||
conversation.is_unread && conversation.destination_hash !== selectedDestinationHash
|
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)"
|
||||||
class="my-auto ml-1"
|
|
||||||
>
|
>
|
||||||
<div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
|
<MaterialDesignIcon icon-name="dots-vertical" class="size-4 text-gray-400" />
|
||||||
</div>
|
</button>
|
||||||
<div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
|
|
||||||
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -430,6 +440,31 @@
|
|||||||
<span class="font-medium">Mark as Read</span>
|
<span class="font-medium">Mark as Read</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
|
<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
|
<div
|
||||||
class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
|
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 MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
import LxmfUserIcon from "../LxmfUserIcon.vue";
|
import LxmfUserIcon from "../LxmfUserIcon.vue";
|
||||||
import GlobalState from "../../js/GlobalState";
|
import GlobalState from "../../js/GlobalState";
|
||||||
|
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||||
|
import MarkdownRenderer from "../../js/MarkdownRenderer";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "MessagesSidebar",
|
name: "MessagesSidebar",
|
||||||
@@ -749,6 +786,7 @@ export default {
|
|||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
targetHash: null,
|
targetHash: null,
|
||||||
|
targetContact: null,
|
||||||
},
|
},
|
||||||
draggedHash: null,
|
draggedHash: null,
|
||||||
dragOverFolderId: 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: {
|
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() {
|
toggleSelectionMode() {
|
||||||
this.selectionMode = !this.selectionMode;
|
this.selectionMode = !this.selectionMode;
|
||||||
if (!this.selectionMode) {
|
if (!this.selectionMode) {
|
||||||
@@ -834,7 +900,7 @@ export default {
|
|||||||
this.selectedHashes.add(hash);
|
this.selectedHashes.add(hash);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRightClick(event, hash) {
|
async onRightClick(event, hash) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (this.selectionMode && !this.selectedHashes.has(hash)) {
|
if (this.selectionMode && !this.selectedHashes.has(hash)) {
|
||||||
this.selectedHashes.add(hash);
|
this.selectedHashes.add(hash);
|
||||||
@@ -843,6 +909,10 @@ export default {
|
|||||||
this.contextMenu.y = event.clientY;
|
this.contextMenu.y = event.clientY;
|
||||||
this.contextMenu.targetHash = hash;
|
this.contextMenu.targetHash = hash;
|
||||||
this.contextMenu.show = true;
|
this.contextMenu.show = true;
|
||||||
|
this.contextMenu.targetContact = null;
|
||||||
|
|
||||||
|
// fetch contact info for trust status
|
||||||
|
await this.fetchContactForContextMenu(hash);
|
||||||
},
|
},
|
||||||
onFolderContextMenu(event) {
|
onFolderContextMenu(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -897,6 +967,38 @@ export default {
|
|||||||
this.$emit("delete-folder", folder.id);
|
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() {
|
bulkMarkAsRead() {
|
||||||
const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
|
const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
|
||||||
this.$emit("bulk-mark-as-read", hashes);
|
this.$emit("bulk-mark-as-read", hashes);
|
||||||
|
|||||||
@@ -277,38 +277,37 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async sendPaperMessage() {
|
async sendPaperMessage() {
|
||||||
const canvas = this.$refs.qrcode;
|
if (!this.recipientHash || !this.uri) return;
|
||||||
if (!canvas || !this.recipientHash || !this.uri) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.isSending = true;
|
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
|
// build lxmf message
|
||||||
const lxmf_message = {
|
const lxmf_message = {
|
||||||
destination_hash: this.recipientHash,
|
destination_hash: this.recipientHash,
|
||||||
content: this.uri,
|
content: `Please scan the attached Paper Message or manually ingest this URI: ${this.uri}`,
|
||||||
fields: {
|
fields: {},
|
||||||
image: {
|
|
||||||
image_type: "png",
|
|
||||||
image_bytes: imageBytes,
|
|
||||||
name: "qrcode.png",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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
|
// send message
|
||||||
const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
|
const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
|
||||||
delivery_method: "opportunistic",
|
delivery_method: "opportunistic",
|
||||||
lxmf_message: lxmf_message,
|
lxmf_message: lxmf_message,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.status === "success") {
|
if (response.data.lxmf_message) {
|
||||||
ToastUtils.success(this.$t("messages.paper_message_sent"));
|
ToastUtils.success(this.$t("messages.paper_message_sent"));
|
||||||
this.close();
|
this.close();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -171,6 +171,14 @@ export default {
|
|||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
this.stop();
|
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: {
|
methods: {
|
||||||
async start() {
|
async start() {
|
||||||
// do nothing if already running
|
// do nothing if already running
|
||||||
|
|||||||
@@ -28,14 +28,17 @@
|
|||||||
<!-- eslint-disable vue/no-v-html -->
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<p
|
<p
|
||||||
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
|
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
|
||||||
|
@click="handleMessageClick"
|
||||||
v-html="renderMarkdown($t('rncp.step_1'))"
|
v-html="renderMarkdown($t('rncp.step_1'))"
|
||||||
></p>
|
></p>
|
||||||
<p
|
<p
|
||||||
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
|
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
|
||||||
|
@click="handleMessageClick"
|
||||||
v-html="renderMarkdown($t('rncp.step_2'))"
|
v-html="renderMarkdown($t('rncp.step_2'))"
|
||||||
></p>
|
></p>
|
||||||
<p
|
<p
|
||||||
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
|
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
|
||||||
|
@click="handleMessageClick"
|
||||||
v-html="renderMarkdown($t('rncp.step_3'))"
|
v-html="renderMarkdown($t('rncp.step_3'))"
|
||||||
></p>
|
></p>
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
@@ -336,6 +339,7 @@
|
|||||||
import DialogUtils from "../../js/DialogUtils";
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||||
|
import MarkdownRenderer from "../../js/MarkdownRenderer";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "RNCPPage",
|
name: "RNCPPage",
|
||||||
@@ -520,8 +524,23 @@ export default {
|
|||||||
this.listenResult = null;
|
this.listenResult = null;
|
||||||
},
|
},
|
||||||
renderMarkdown(text) {
|
renderMarkdown(text) {
|
||||||
if (!text) return "";
|
return MarkdownRenderer.render(text);
|
||||||
return text.replace(/\*\*(.*?)\*\*/g, "<b>$1</b>");
|
},
|
||||||
|
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
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
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...'"
|
:placeholder="$t('app.search_settings') || 'Search settings...'"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -687,6 +687,165 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Language -->
|
||||||
<section
|
<section
|
||||||
v-show="
|
v-show="
|
||||||
@@ -713,6 +872,7 @@
|
|||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
<option value="de">Deutsch</option>
|
<option value="de">Deutsch</option>
|
||||||
<option value="ru">Русский</option>
|
<option value="ru">Русский</option>
|
||||||
|
<option value="it">Italiano</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -959,6 +1119,98 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Messages -->
|
||||||
<section v-show="matchesSearch(...sectionKeywords.messages)" class="glass-card break-inside-avoid">
|
<section v-show="matchesSearch(...sectionKeywords.messages)" class="glass-card break-inside-avoid">
|
||||||
<header class="glass-card__header">
|
<header class="glass-card__header">
|
||||||
@@ -1256,13 +1508,24 @@ export default {
|
|||||||
message_icon_size: 28,
|
message_icon_size: 28,
|
||||||
telephone_tone_generator_enabled: true,
|
telephone_tone_generator_enabled: true,
|
||||||
telephone_tone_generator_volume: 50,
|
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",
|
gitea_base_url: "https://git.quad4.io",
|
||||||
docs_download_urls: "",
|
docs_download_urls: "",
|
||||||
|
csp_extra_connect_src: "",
|
||||||
|
csp_extra_img_src: "",
|
||||||
|
csp_extra_frame_src: "",
|
||||||
|
csp_extra_script_src: "",
|
||||||
|
csp_extra_style_src: "",
|
||||||
},
|
},
|
||||||
saveTimeouts: {},
|
saveTimeouts: {},
|
||||||
shortcuts: [],
|
shortcuts: [],
|
||||||
reloadingRns: false,
|
reloadingRns: false,
|
||||||
searchQuery: "",
|
searchQuery: "",
|
||||||
|
trustedTelemetryPeers: [],
|
||||||
sectionKeywords: {
|
sectionKeywords: {
|
||||||
banishment: [
|
banishment: [
|
||||||
"Visuals",
|
"Visuals",
|
||||||
@@ -1305,6 +1568,18 @@ export default {
|
|||||||
],
|
],
|
||||||
archiver: ["Browsing", "Page Archiver", "archiver", "archive", "versions", "storage", "flush"],
|
archiver: ["Browsing", "Page Archiver", "archiver", "archive", "versions", "storage", "flush"],
|
||||||
crawler: ["Discovery", "Smart Crawler", "crawler", "crawl", "retries", "delay", "concurrent"],
|
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: [
|
appearance: [
|
||||||
"Personalise",
|
"Personalise",
|
||||||
"app.appearance",
|
"app.appearance",
|
||||||
@@ -1374,6 +1649,17 @@ export default {
|
|||||||
"app.propagation_stamp_cost",
|
"app.propagation_stamp_cost",
|
||||||
"app.propagation_stamp_description",
|
"app.propagation_stamp_description",
|
||||||
],
|
],
|
||||||
|
location: [
|
||||||
|
"Location",
|
||||||
|
"GPS",
|
||||||
|
"Privacy",
|
||||||
|
"manual",
|
||||||
|
"latitude",
|
||||||
|
"longitude",
|
||||||
|
"altitude",
|
||||||
|
"telemetry",
|
||||||
|
"trusted peers",
|
||||||
|
],
|
||||||
shortcuts: ["Keyboard Shortcuts", "actions", "workflow"],
|
shortcuts: ["Keyboard Shortcuts", "actions", "workflow"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1391,6 +1677,10 @@ export default {
|
|||||||
lxmf_address_hash: "",
|
lxmf_address_hash: "",
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
is_transport_enabled: false,
|
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;
|
return this.config;
|
||||||
@@ -1419,8 +1709,29 @@ export default {
|
|||||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||||
|
|
||||||
this.getConfig();
|
this.getConfig();
|
||||||
|
this.getTrustedTelemetryPeers();
|
||||||
},
|
},
|
||||||
methods: {
|
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) {
|
matchesSearch(...texts) {
|
||||||
if (!this.searchQuery) return true;
|
if (!this.searchQuery) return true;
|
||||||
const query = this.searchQuery.toLowerCase();
|
const query = this.searchQuery.toLowerCase();
|
||||||
@@ -1787,6 +2098,21 @@ export default {
|
|||||||
);
|
);
|
||||||
}, 1000);
|
}, 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() {
|
async onBackupConfigChange() {
|
||||||
if (this.saveTimeouts.backup) clearTimeout(this.saveTimeouts.backup);
|
if (this.saveTimeouts.backup) clearTimeout(this.saveTimeouts.backup);
|
||||||
this.saveTimeouts.backup = setTimeout(async () => {
|
this.saveTimeouts.backup = setTimeout(async () => {
|
||||||
|
|||||||
@@ -267,12 +267,7 @@ export default {
|
|||||||
}
|
}
|
||||||
} else if (json.type === "lxm.ingest_uri.result") {
|
} else if (json.type === "lxm.ingest_uri.result") {
|
||||||
if (json.status === "success") {
|
if (json.status === "success") {
|
||||||
ToastUtils.success(json.message);
|
|
||||||
this.ingestUri = "";
|
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,
|
lxmf_message: lxmf_message,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.status === "success") {
|
if (response.data.lxmf_message) {
|
||||||
ToastUtils.success(this.$t("messages.paper_message_sent"));
|
ToastUtils.success(this.$t("messages.paper_message_sent"));
|
||||||
this.generatedUri = null;
|
this.generatedUri = null;
|
||||||
this.destinationHash = "";
|
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="flex-1 overflow-y-auto w-full">
|
||||||
<div class="space-y-4 p-4 md:p-6 lg:p-8 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="glass-card flex flex-col md:flex-row md:items-center justify-between gap-6 p-6 md:p-8">
|
||||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
<div class="space-y-3 flex-1 min-w-0">
|
||||||
{{ $t("tools.utilities") }}
|
<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>
|
||||||
<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="w-full md:w-80 shrink-0">
|
||||||
<div class="relative">
|
<div class="relative group">
|
||||||
<MaterialDesignIcon
|
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||||
icon-name="magnify"
|
<MaterialDesignIcon
|
||||||
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
|
icon-name="magnify"
|
||||||
/>
|
class="size-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
|
||||||
<input
|
/>
|
||||||
v-model="searchQuery"
|
</div>
|
||||||
type="text"
|
<input
|
||||||
:placeholder="$t('common.search')"
|
v-model="searchQuery"
|
||||||
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -147,6 +158,14 @@ export default {
|
|||||||
titleKey: "tools.rnpath.title",
|
titleKey: "tools.rnpath.title",
|
||||||
descriptionKey: "tools.rnpath.description",
|
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",
|
name: "translator",
|
||||||
route: { name: "translator" },
|
route: { name: "translator" },
|
||||||
|
|||||||
Reference in New Issue
Block a user