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:
2026-01-07 19:13:20 -06:00
parent df306cc67b
commit 37d4b317b9
19 changed files with 2691 additions and 411 deletions

View File

@@ -129,7 +129,7 @@
class="fixed inset-y-0 left-0 z-[70] transform transition-all duration-300 ease-in-out sm:relative sm:z-0 sm:flex sm:translate-x-0"
:class="[
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
isSidebarCollapsed ? 'w-16' : 'w-72',
isSidebarCollapsed ? 'w-16' : 'w-80',
]"
>
<div

View File

@@ -215,6 +215,14 @@ export default {
type: "navigation",
route: { name: "rnpath" },
},
{
id: "nav-rnpath-trace",
title: "nav_rnpath_trace",
description: "nav_rnpath_trace_desc",
icon: "map-marker-path",
type: "navigation",
route: { name: "rnpath-trace" },
},
{
id: "nav-translator",
title: "nav_translator",

View File

@@ -70,6 +70,7 @@ export default {
{ code: "en", name: "English" },
{ code: "de", name: "Deutsch" },
{ code: "ru", name: "Русский" },
{ code: "it", name: "Italiano" },
],
};
},

View File

@@ -24,6 +24,11 @@
icon-name="alert"
class="h-6 w-6 text-amber-500"
/>
<MaterialDesignIcon
v-else-if="toast.type === 'loading'"
icon-name="loading"
class="h-6 w-6 text-blue-500 animate-spin"
/>
<MaterialDesignIcon v-else icon-name="information" class="h-6 w-6 text-blue-500" />
</div>
@@ -70,24 +75,58 @@ export default {
},
methods: {
add(toast) {
// Check if a toast with the same key already exists
if (toast.key) {
const existingIndex = this.toasts.findIndex((t) => t.key === toast.key);
if (existingIndex !== -1) {
const existingToast = this.toasts[existingIndex];
// Clear existing timeout if it exists
if (existingToast.timer) {
clearTimeout(existingToast.timer);
}
// Update existing toast
existingToast.message = toast.message;
existingToast.type = toast.type || "info";
existingToast.duration = toast.duration !== undefined ? toast.duration : 5000;
if (existingToast.duration > 0) {
existingToast.timer = setTimeout(() => {
this.remove(existingToast.id);
}, existingToast.duration);
} else {
existingToast.timer = null;
}
return;
}
}
const id = this.counter++;
const newToast = {
id,
key: toast.key,
message: toast.message,
type: toast.type || "info",
duration: toast.duration || 5000,
duration: toast.duration !== undefined ? toast.duration : 5000,
timer: null,
};
this.toasts.push(newToast);
if (newToast.duration > 0) {
setTimeout(() => {
newToast.timer = setTimeout(() => {
this.remove(id);
}, newToast.duration);
}
this.toasts.push(newToast);
},
remove(id) {
const index = this.toasts.findIndex((t) => t.id === id);
if (index !== -1) {
const toast = this.toasts[index];
if (toast.timer) {
clearTimeout(toast.timer);
}
this.toasts.splice(index, 1);
}
},

View File

@@ -402,7 +402,7 @@
>
{{
appInfo.is_connected_to_shared_instance
? "Shared Instance"
? `Shared Instance: ${appInfo.shared_instance_address || "unknown"}`
: "Main Instance"
}}
</div>
@@ -659,6 +659,14 @@
<div
class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
type="button"
class="primary-chip !px-3 !py-1 !text-[10px]"
@click="downloadSnapshot(snapshot.name)"
>
<v-icon icon="mdi-download" size="12" start></v-icon>
Download
</button>
<button
type="button"
class="secondary-chip !px-3 !py-1 !text-[10px]"
@@ -742,6 +750,14 @@
<div
class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
type="button"
class="primary-chip !px-3 !py-1 !text-[10px]"
@click="downloadBackupFile(backup.name)"
>
<v-icon icon="mdi-download" size="12" start></v-icon>
Download
</button>
<button
type="button"
class="secondary-chip !px-3 !py-1 !text-[10px]"
@@ -1001,6 +1017,42 @@ export default {
console.log("Failed to list auto-backups");
}
},
async downloadSnapshot(filename) {
try {
const response = await window.axios.get(`/api/v1/database/snapshots/${filename}/download`, {
responseType: "blob",
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
ToastUtils.success("Snapshot downloaded");
} catch {
ToastUtils.error("Failed to download snapshot");
}
},
async downloadBackupFile(filename) {
try {
const response = await window.axios.get(`/api/v1/database/backups/${filename}/download`, {
responseType: "blob",
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
ToastUtils.success("Backup downloaded");
} catch {
ToastUtils.error("Failed to download backup");
}
},
async deleteSnapshot(filename) {
if (!(await DialogUtils.confirm(this.$t("about.delete_snapshot_confirm")))) return;
try {

View File

@@ -26,20 +26,18 @@
</button>
</div>
<div class="glass-card space-y-4">
<div class="flex flex-wrap gap-3 items-center">
<div class="flex-1 min-w-0">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("interfaces.manage") }}
</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white truncate">
{{ $t("interfaces.title") }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $t("interfaces.description") }}
</div>
<div class="glass-card flex flex-col md:flex-row md:items-center justify-between gap-6 p-6 md:p-8">
<div class="space-y-3 flex-1 min-w-0">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("interfaces.manage") }}
</div>
<div class="flex flex-wrap gap-2">
<div class="text-3xl font-black text-gray-900 dark:text-white tracking-tight">
{{ $t("interfaces.title") }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed max-w-xl">
{{ $t("interfaces.description") }}
</div>
<div class="flex flex-wrap gap-2 pt-2">
<RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
<MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
{{ $t("interfaces.add_interface") }}
@@ -52,56 +50,34 @@
<MaterialDesignIcon icon-name="export" class="w-4 h-4" />
{{ $t("interfaces.export_all") }}
</button>
<!--
<button
type="button"
class="secondary-chip text-sm bg-amber-500/10 hover:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
:disabled="reloadingRns"
@click="reloadRns"
>
<MaterialDesignIcon
:icon-name="reloadingRns ? 'refresh' : 'restart'"
class="w-4 h-4"
:class="{ 'animate-spin-reverse': reloadingRns }"
/>
{{ reloadingRns ? $t("app.reloading_rns") : $t("app.reload_rns") }}
</button>
--></div>
</div>
</div>
<div class="flex flex-wrap gap-3 items-center">
<div class="flex-1">
<div class="w-full md:w-96 shrink-0 space-y-4">
<div class="relative group">
<MaterialDesignIcon
icon-name="magnify"
class="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
/>
<input
v-model="searchTerm"
type="text"
:placeholder="$t('interfaces.search_placeholder')"
class="input-field"
class="w-full pl-12 pr-4 py-3 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 shadow-sm"
/>
</div>
<div class="flex gap-2 flex-wrap">
<button
type="button"
:class="filterChipClass(statusFilter === 'all')"
@click="setStatusFilter('all')"
v-if="searchTerm"
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
@click="searchTerm = ''"
>
{{ $t("interfaces.all") }}
</button>
<button
type="button"
:class="filterChipClass(statusFilter === 'enabled')"
@click="setStatusFilter('enabled')"
>
{{ $t("app.enabled") }}
</button>
<button
type="button"
:class="filterChipClass(statusFilter === 'disabled')"
@click="setStatusFilter('disabled')"
>
{{ $t("app.disabled") }}
<MaterialDesignIcon icon-name="close-circle" class="size-5" />
</button>
</div>
<div class="w-full sm:w-60">
<select v-model="typeFilter" class="input-field">
<div>
<select
v-model="typeFilter"
class="w-full px-4 py-2.5 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 text-gray-900 dark:text-white"
>
<option value="all">{{ $t("interfaces.all_types") }}</option>
<option v-for="type in sortedInterfaceTypes" :key="type" :value="type">
{{ type }}
@@ -127,10 +103,47 @@
<div v-if="activeTab === 'overview'" class="space-y-4">
<div class="glass-card space-y-3">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Configured
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="space-y-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Configured
</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">
Interfaces
<span
v-if="filteredInterfaces.length > 0"
class="ml-2 text-sm font-medium text-gray-400"
>({{ filteredInterfaces.length }})</span
>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<button
type="button"
:class="filterChipClass(statusFilter === 'all')"
class="!py-1 !px-3"
@click="setStatusFilter('all')"
>
{{ $t("interfaces.all") }}
</button>
<button
type="button"
:class="filterChipClass(statusFilter === 'enabled')"
class="!py-1 !px-3"
@click="setStatusFilter('enabled')"
>
{{ $t("app.enabled") }}
</button>
<button
type="button"
:class="filterChipClass(statusFilter === 'disabled')"
class="!py-1 !px-3"
@click="setStatusFilter('disabled')"
>
{{ $t("app.disabled") }}
</button>
</div>
</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
<div
v-if="filteredInterfaces.length !== 0"
class="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3 3xl:grid-cols-4 4xl:grid-cols-5"
@@ -162,13 +175,36 @@
</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">
Recently Heard Announces
<span
v-if="sortedDiscoveredInterfaces.length > 0"
class="ml-2 text-sm font-medium text-gray-400"
>({{ sortedDiscoveredInterfaces.length }})</span
>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Discovery runs continually; heard announces stay listed. Connected entries show
a green pill; disconnected entries are dimmed with a red label.
</div>
</div>
<div class="flex gap-2">
<div class="flex gap-2 flex-wrap items-center">
<div class="flex gap-1.5 mr-2">
<button
type="button"
:class="filterChipClass(discoveredStatusFilter === 'all')"
class="!py-1 !px-3"
@click="discoveredStatusFilter = 'all'"
>
{{ $t("interfaces.all") }}
</button>
<button
type="button"
:class="filterChipClass(discoveredStatusFilter === 'connected')"
class="!py-1 !px-3"
@click="discoveredStatusFilter = 'connected'"
>
{{ $t("interfaces.connected_only") }}
</button>
</div>
<button
v-if="interfacesWithLocation.length > 0"
type="button"
@@ -213,7 +249,8 @@
class="absolute inset-0 z-10 flex items-center justify-center bg-white/20 dark:bg-zinc-900/20 backdrop-blur-[0.5px] rounded-3xl pointer-events-none"
>
<div
class="bg-red-500/90 text-white px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider animate-pulse"
class="bg-red-500/90 text-white px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider"
:class="{ 'animate-pulse': shouldAnimatePulse(iface) }"
>
<MaterialDesignIcon icon-name="lan-disconnect" class="w-3.5 h-3.5" />
<span>{{ $t("app.disabled") }}</span>
@@ -522,6 +559,7 @@ export default {
savingDiscovery: false,
discoveredInterfaces: [],
discoveredActive: [],
discoveredStatusFilter: "all",
discoveryInterval: null,
activeTab: "overview",
};
@@ -596,7 +634,31 @@ export default {
return Array.from(types).sort();
},
sortedDiscoveredInterfaces() {
return [...this.discoveredInterfaces].sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
const search = this.searchTerm.toLowerCase().trim();
let list = [...this.discoveredInterfaces];
if (this.discoveredStatusFilter === "connected") {
list = list.filter((iface) => this.isDiscoveredConnected(iface));
}
if (this.typeFilter !== "all") {
list = list.filter((iface) => iface.type === this.typeFilter);
}
if (search) {
list = list.filter((iface) => {
const haystack = [
iface.name,
iface.type,
iface.reachable_on,
iface.port,
iface.transport_id,
iface.network_id,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(search);
});
}
return list.sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
},
interfacesWithLocation() {
return this.discoveredInterfaces.filter((iface) => iface.latitude != null && iface.longitude != null);
@@ -806,6 +868,7 @@ export default {
...iface,
last_heard: lastHeard,
__isNew: isNew || existing?.__isNew,
disconnected_at: existing?.disconnected_at ?? null,
});
};
@@ -814,6 +877,18 @@ export default {
this.discoveredInterfaces = Array.from(merged.values());
this.discoveredActive = active;
// Track disconnection time for animation control
const now = Date.now();
this.discoveredInterfaces.forEach((iface) => {
if (!this.isDiscoveredConnected(iface)) {
if (iface.disconnected_at === null) {
iface.disconnected_at = now;
}
} else {
iface.disconnected_at = null;
}
});
} catch (e) {
console.log(e);
}
@@ -851,6 +926,13 @@ export default {
return hostMatch && portMatch && (s.connected || s.online);
});
},
shouldAnimatePulse(iface) {
if (this.isDiscoveredConnected(iface)) return false;
if (!iface.disconnected_at) return true;
// only pulse for the first 30 seconds of being disconnected
// to avoid continuous UI animation lag on many disconnected items
return Date.now() - iface.disconnected_at < 30000;
},
goToMap(iface) {
if (iface.latitude == null || iface.longitude == null) return;
this.$router.push({

View File

File diff suppressed because it is too large Load Diff

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

View File

@@ -2,7 +2,7 @@
<DropDownMenu>
<template #button>
<IconButton>
<MaterialDesignIcon icon-name="dots-vertical" class="size-6" />
<MaterialDesignIcon icon-name="dots-vertical" class="size-7" />
</IconButton>
</template>
<template #items>
@@ -43,6 +43,22 @@
<span class="text-red-500">Delete Message History</span>
</DropDownMenuItem>
</div>
<!-- telemetry trust toggle -->
<div v-if="GlobalState.config.telemetry_enabled" class="border-t">
<DropDownMenuItem @click="onToggleTelemetryTrust">
<MaterialDesignIcon
:icon-name="contact?.is_telemetry_trusted ? 'shield-check' : 'shield-outline'"
:class="contact?.is_telemetry_trusted ? 'text-blue-500' : 'text-gray-500'"
class="size-5"
/>
<span>{{
contact?.is_telemetry_trusted
? $t("app.telemetry_trust_revoke")
: $t("app.telemetry_trust_grant")
}}</span>
</DropDownMenuItem>
</div>
</template>
</DropDownMenu>
</template>
@@ -70,7 +86,18 @@ export default {
required: true,
},
},
emits: ["conversation-deleted", "set-custom-display-name", "block-status-changed", "popout"],
emits: [
"conversation-deleted",
"set-custom-display-name",
"block-status-changed",
"popout",
"view-telemetry-history",
],
data() {
return {
contact: null,
};
},
computed: {
isBlocked() {
if (!this.peer) {
@@ -79,7 +106,72 @@ export default {
return GlobalState.blockedDestinations.some((b) => b.destination_hash === this.peer.destination_hash);
},
},
watch: {
peer: {
immediate: true,
handler() {
this.fetchContact();
},
},
},
mounted() {
GlobalEmitter.on("contact-updated", this.onContactUpdated);
},
unmounted() {
GlobalEmitter.off("contact-updated", this.onContactUpdated);
},
methods: {
onContactUpdated(data) {
if (this.peer?.destination_hash === data.remote_identity_hash) {
this.fetchContact();
}
},
async fetchContact() {
if (!this.peer || !this.peer.destination_hash) return;
try {
const response = await window.axios.get(
`/api/v1/telephone/contacts/check/${this.peer.destination_hash}`
);
if (response.data.is_contact) {
this.contact = response.data.contact;
} else {
this.contact = null;
}
} catch (e) {
console.error("Failed to fetch contact", e);
}
},
async onToggleTelemetryTrust() {
const newStatus = !this.contact?.is_telemetry_trusted;
try {
if (!this.contact) {
// create contact first
await window.axios.post("/api/v1/telephone/contacts", {
name: this.peer.display_name,
remote_identity_hash: this.peer.destination_hash,
is_telemetry_trusted: true,
});
await this.fetchContact();
} else {
await window.axios.patch(`/api/v1/telephone/contacts/${this.contact.id}`, {
is_telemetry_trusted: newStatus,
});
this.contact.is_telemetry_trusted = newStatus;
}
GlobalEmitter.emit("contact-updated", {
remote_identity_hash: this.peer.destination_hash,
is_telemetry_trusted: newStatus,
});
DialogUtils.alert(
newStatus
? this.$t("app.telemetry_trust_granted_alert")
: this.$t("app.telemetry_trust_revoked_alert")
);
} catch (e) {
DialogUtils.alert(this.$t("app.telemetry_trust_failed"));
console.error(e);
}
},
async onBlockDestination() {
if (
!(await DialogUtils.confirm(

View File

@@ -57,24 +57,32 @@
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
</div>
</div>
<div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
<div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5 flex items-center gap-2 min-w-0">
<!-- destination hash -->
<div class="inline-block mr-1">
<div
class="cursor-pointer hover:text-blue-500 transition-colors"
:title="selectedPeer.destination_hash"
@click="copyHash(selectedPeer.destination_hash)"
>
{{ formatDestinationHash(selectedPeer.destination_hash) }}
</div>
<div
class="cursor-pointer hover:text-blue-500 transition-colors truncate max-w-[120px] sm:max-w-none shrink-0"
:title="selectedPeer.destination_hash"
@click="copyHash(selectedPeer.destination_hash)"
>
{{ formatDestinationHash(selectedPeer.destination_hash) }}
</div>
<div class="inline-block">
<div class="flex space-x-1">
<div
v-if="
selectedPeerPath ||
selectedPeerSignalMetrics?.snr != null ||
selectedPeerLxmfStampInfo?.stamp_cost
"
class="flex items-center gap-2 min-w-0"
>
<span class="text-gray-300 dark:text-zinc-700 shrink-0">•</span>
<div class="flex items-center gap-2 truncate">
<!-- hops away -->
<span
v-if="selectedPeerPath"
class="flex my-auto cursor-pointer"
class="flex items-center cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200 shrink-0"
title="Path information"
@click="onDestinationPathClick(selectedPeerPath)"
>
<span v-if="selectedPeerPath.hops === 0 || selectedPeerPath.hops === 1">{{
@@ -84,19 +92,30 @@
</span>
<!-- snr -->
<span v-if="selectedPeerSignalMetrics?.snr != null" class="flex my-auto space-x-1">
<span v-if="selectedPeerPath"></span>
<span class="cursor-pointer" @click="onSignalMetricsClick(selectedPeerSignalMetrics)">{{
$t("messages.snr", { snr: selectedPeerSignalMetrics.snr })
}}</span>
<span
v-if="selectedPeerSignalMetrics?.snr != null"
class="flex items-center gap-2 shrink-0"
>
<span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
<span
class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
title="Signal quality"
@click="onSignalMetricsClick(selectedPeerSignalMetrics)"
>{{ $t("messages.snr", { snr: selectedPeerSignalMetrics.snr }) }}</span
>
</span>
<!-- stamp cost -->
<span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex my-auto space-x-1">
<span v-if="selectedPeerPath || selectedPeerSignalMetrics?.snr != null"></span>
<span class="cursor-pointer" @click="onStampInfoClick(selectedPeerLxmfStampInfo)">{{
$t("messages.stamp_cost", { cost: selectedPeerLxmfStampInfo.stamp_cost })
}}</span>
<span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex items-center gap-2 shrink-0">
<span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
<span
class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
title="LXMF stamp requirement"
@click="onStampInfoClick(selectedPeerLxmfStampInfo)"
>{{
$t("messages.stamp_cost", { cost: selectedPeerLxmfStampInfo.stamp_cost })
}}</span
>
</span>
</div>
</div>
@@ -104,7 +123,7 @@
</div>
<!-- dropdown menu -->
<div class="ml-auto flex items-center gap-1">
<div class="ml-auto flex items-center gap-1.5">
<!-- retry all failed messages -->
<IconButton
v-if="hasFailedOrCancelledMessages"
@@ -112,7 +131,22 @@
class="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
@click="retryAllFailedOrCancelledMessages"
>
<MaterialDesignIcon icon-name="refresh" class="size-6" />
<MaterialDesignIcon icon-name="refresh" class="size-7" />
</IconButton>
<!-- telemetry history button -->
<IconButton title="View Telemetry History" @click="isTelemetryHistoryModalOpen = true">
<MaterialDesignIcon icon-name="satellite-variant" class="size-7" />
</IconButton>
<!-- call button -->
<IconButton title="Start a Call" @click="onStartCall">
<MaterialDesignIcon icon-name="phone" class="size-7" />
</IconButton>
<!-- share contact button -->
<IconButton title="Share Contact" @click="openShareContactModal">
<MaterialDesignIcon icon-name="notebook-outline" class="size-7" />
</IconButton>
<ConversationDropDownMenu
@@ -123,23 +157,151 @@
@popout="openConversationPopout"
/>
<!-- call button -->
<IconButton title="Start a Call" @click="onStartCall">
<MaterialDesignIcon icon-name="phone" class="size-6" />
</IconButton>
<!-- share contact button -->
<IconButton title="Share Contact" @click="openShareContactModal">
<MaterialDesignIcon icon-name="notebook-outline" class="size-6" />
</IconButton>
<!-- close button -->
<IconButton title="Close" @click="close">
<MaterialDesignIcon icon-name="close" class="size-6" />
<MaterialDesignIcon icon-name="close" class="size-7" />
</IconButton>
</div>
</div>
<!-- Telemetry History Modal -->
<div
v-if="isTelemetryHistoryModalOpen"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click.self="isTelemetryHistoryModalOpen = false"
>
<div
class="w-full max-w-lg bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh]"
>
<div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<div class="flex items-center gap-2">
<MaterialDesignIcon icon-name="satellite-variant" class="size-6 text-blue-500" />
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Telemetry History</h3>
</div>
<button
type="button"
class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
@click="isTelemetryHistoryModalOpen = false"
>
<MaterialDesignIcon icon-name="close" class="size-6" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-3">
<div v-if="selectedPeerTelemetryItems.length === 0" class="text-center py-8 text-gray-400">
No telemetry history found for this peer.
</div>
<div
v-for="item in selectedPeerTelemetryItems"
:key="item.lxmf_message.hash"
class="p-3 rounded-xl border border-gray-100 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/30"
>
<div class="flex justify-between items-start mb-2">
<span
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-gray-200 dark:bg-zinc-800 text-gray-600 dark:text-zinc-400"
>
{{ item.is_outbound ? "Sent" : "Received" }}
</span>
<span class="text-[10px] text-gray-400">{{
formatTimeAgo(item.lxmf_message.created_at)
}}</span>
</div>
<!-- location -->
<div v-if="item.lxmf_message.fields?.telemetry?.location" class="flex items-center gap-2 mb-2">
<button
type="button"
class="flex items-center gap-2 text-xs font-mono text-blue-600 dark:text-blue-400 hover:underline"
@click="viewLocationOnMap(item.lxmf_message.fields.telemetry.location)"
>
<MaterialDesignIcon icon-name="map-marker" class="size-4" />
{{ item.lxmf_message.fields.telemetry.location.latitude.toFixed(6) }},
{{ item.lxmf_message.fields.telemetry.location.longitude.toFixed(6) }}
</button>
</div>
<!-- sensors -->
<div
v-if="item.lxmf_message.fields?.telemetry"
class="flex flex-wrap gap-3 text-[10px] text-gray-500"
>
<span v-if="item.lxmf_message.fields.telemetry.battery" class="flex items-center gap-1">
<MaterialDesignIcon icon-name="battery" class="size-3" />
Battery: {{ item.lxmf_message.fields.telemetry.battery.charge_percent }}%
</span>
<span
v-if="item.lxmf_message.fields.telemetry.physical_link"
class="flex items-center gap-1"
>
<MaterialDesignIcon icon-name="antenna" class="size-3" />
SNR: {{ item.lxmf_message.fields.telemetry.physical_link.snr }}dB
</span>
</div>
<!-- commands -->
<div
v-if="item.lxmf_message.fields?.commands?.some((c) => c['0x01'])"
class="flex items-center gap-2 text-[10px] text-emerald-600 dark:text-emerald-400 mt-1"
>
<MaterialDesignIcon icon-name="crosshairs-question" class="size-3" />
<span>Location Request</span>
</div>
</div>
</div>
<div
class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 bg-gray-50/30 dark:bg-zinc-900/20 flex flex-col gap-4"
>
<div v-if="telemetryBatteryHistory.length > 1" class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-[10px] font-bold text-gray-400 uppercase tracking-widest"
>Battery Level (%)</span
>
<span class="text-[10px] text-gray-500"
>{{ telemetryBatteryHistory[0].y }}% →
{{ telemetryBatteryHistory[telemetryBatteryHistory.length - 1].y }}%</span
>
</div>
<svg class="w-full h-12 overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
<path
:d="batterySparklinePath"
fill="none"
stroke="#3b82f6"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
:cx="100"
:cy="100 - telemetryBatteryHistory[telemetryBatteryHistory.length - 1].y"
r="3"
fill="#3b82f6"
/>
</svg>
</div>
<div class="flex justify-between items-center w-full">
<label class="flex items-center gap-2 cursor-pointer group">
<input
v-model="showTelemetryInChat"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span
class="text-xs font-medium text-gray-600 dark:text-zinc-400 group-hover:text-gray-900 dark:group-hover:text-zinc-200"
>Show telemetry in main chat</span
>
</label>
<button
type="button"
class="px-4 py-2 bg-blue-600 text-white text-xs font-bold rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
@click="isTelemetryHistoryModalOpen = false"
>
Done
</button>
</div>
</div>
</div>
</div>
<!-- Share Contact Modal -->
<div
v-if="isShareContactModalOpen"
@@ -264,15 +426,54 @@
</div>
<!-- content -->
<!-- eslint-disable vue/no-v-html -->
<div
v-if="chatItem.lxmf_message.content && !getParsedItems(chatItem)?.isOnlyPaperMessage"
class="leading-relaxed whitespace-pre-wrap break-words [word-break:break-word] min-w-0"
class="leading-relaxed break-words [word-break:break-word] min-w-0 markdown-content"
:style="{
'font-family': 'inherit',
'font-size': (config?.message_font_size || 14) + 'px',
}"
@click="handleMessageClick"
v-html="renderMarkdown(chatItem.lxmf_message.content)"
></div>
<!-- eslint-enable vue/no-v-html -->
<!-- telemetry placeholder for empty content messages -->
<div
v-if="!chatItem.lxmf_message.content && chatItem.lxmf_message.fields?.telemetry"
class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
>
{{ chatItem.lxmf_message.content }}
<MaterialDesignIcon icon-name="satellite-variant" class="size-4 opacity-60" />
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60">
{{ chatItem.is_outbound ? "Telemetry update sent" : "Telemetry update received" }}
</span>
</div>
<div
v-if="!chatItem.lxmf_message.content && chatItem.lxmf_message.fields?.telemetry_stream"
class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
>
<MaterialDesignIcon icon-name="database-sync" class="size-4 opacity-60" />
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60"
>Telemetry stream received ({{
chatItem.lxmf_message.fields.telemetry_stream.length
}}
entries)</span
>
</div>
<div
v-if="
!chatItem.lxmf_message.content &&
chatItem.lxmf_message.fields?.commands?.some((c) => c['0x01'] || c['1'] || c['0x1'])
"
class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
>
<MaterialDesignIcon icon-name="crosshairs-question" class="size-4 opacity-60" />
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60">
{{ chatItem.is_outbound ? "Location Request Sent" : "Location Request Received" }}
</span>
</div>
<!-- parsed items (contacts / paper messages) -->
@@ -481,29 +682,120 @@
</a>
</div>
<!-- telemetry / location field -->
<div v-if="chatItem.lxmf_message.fields?.telemetry?.location" class="pb-1 mt-1">
<button
type="button"
class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
:class="
chatItem.is_outbound
? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
: 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
"
@click="viewLocationOnMap(chatItem.lxmf_message.fields.telemetry.location)"
>
<MaterialDesignIcon icon-name="map-marker" class="size-5" />
<div class="text-left">
<div class="font-bold text-xs uppercase tracking-wider opacity-80">
Location
</div>
<div class="text-[10px] font-mono opacity-70">
{{ chatItem.lxmf_message.fields.telemetry.location.latitude.toFixed(6) }},
{{ chatItem.lxmf_message.fields.telemetry.location.longitude.toFixed(6) }}
<!-- commands -->
<div v-if="chatItem.lxmf_message.fields?.commands" class="space-y-2 mt-1">
<div v-for="(command, index) in chatItem.lxmf_message.fields.commands" :key="index">
<div
v-if="command['0x01'] || command['1'] || command['0x1']"
class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
:class="
chatItem.is_outbound
? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
: 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
"
>
<MaterialDesignIcon icon-name="crosshairs-question" class="size-5" />
<div class="text-left">
<div class="font-bold text-xs uppercase tracking-wider opacity-80">
{{ $t("messages.location_requested") }}
</div>
<div v-if="!chatItem.is_outbound" class="text-[10px] opacity-70">
Peer is requesting your location
</div>
</div>
</div>
</button>
</div>
</div>
<!-- telemetry / location field -->
<div v-if="chatItem.lxmf_message.fields?.telemetry" class="pb-1 mt-1 space-y-2">
<div class="flex flex-wrap gap-2">
<button
v-if="chatItem.lxmf_message.fields.telemetry.location"
type="button"
class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
:class="
chatItem.is_outbound
? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
: 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
"
@click="viewLocationOnMap(chatItem.lxmf_message.fields.telemetry.location)"
>
<MaterialDesignIcon icon-name="map-marker" class="size-5" />
<div class="text-left">
<div class="font-bold text-[10px] uppercase tracking-wider opacity-80">
Location
</div>
<div class="text-[9px] font-mono opacity-70">
{{
chatItem.lxmf_message.fields.telemetry.location.latitude.toFixed(6)
}},
{{
chatItem.lxmf_message.fields.telemetry.location.longitude.toFixed(6)
}}
</div>
</div>
</button>
<!-- Live Track Toggle Button (only for incoming) -->
<button
v-if="!chatItem.is_outbound"
type="button"
class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
:class="[
selectedPeer?.is_tracking
? 'bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/30 shadow-inner'
: 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
]"
@click="toggleTracking()"
>
<MaterialDesignIcon
:icon-name="selectedPeer?.is_tracking ? 'radar' : 'crosshairs'"
class="size-5"
:class="{ 'animate-pulse text-blue-500': selectedPeer?.is_tracking }"
/>
<div class="text-left">
<div class="font-bold text-[10px] uppercase tracking-wider opacity-80">
{{ selectedPeer?.is_tracking ? "Tracking Active" : "Live Track" }}
</div>
<div class="text-[9px] opacity-70">
{{
selectedPeer?.is_tracking
? "Auto-requesting location"
: "Enable live tracking"
}}
</div>
</div>
</button>
</div>
<!-- other sensor data if available -->
<div
v-if="
chatItem.lxmf_message.fields.telemetry.battery ||
chatItem.lxmf_message.fields.telemetry.physical_link
"
class="flex gap-3 px-1"
>
<div
v-if="chatItem.lxmf_message.fields.telemetry.battery"
class="flex items-center gap-1 opacity-60 text-[10px]"
>
<MaterialDesignIcon icon-name="battery" class="size-3" />
<span
>{{ chatItem.lxmf_message.fields.telemetry.battery.charge_percent }}%</span
>
</div>
<div
v-if="chatItem.lxmf_message.fields.telemetry.physical_link"
class="flex items-center gap-1 opacity-60 text-[10px]"
>
<MaterialDesignIcon icon-name="antenna" class="size-3" />
<span
>SNR: {{ chatItem.lxmf_message.fields.telemetry.physical_link.snr }}dB</span
>
</div>
</div>
</div>
</div>
@@ -1395,6 +1687,7 @@ import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
import PaperMessageModal from "./PaperMessageModal.vue";
import GlobalState from "../../js/GlobalState";
import MarkdownRenderer from "../../js/MarkdownRenderer";
export default {
name: "ConversationViewer",
@@ -1428,7 +1721,7 @@ export default {
required: true,
},
},
emits: ["close", "reload-conversations", "update:selectedPeer"],
emits: ["close", "reload-conversations", "update:selectedPeer", "update-peer-tracking"],
data() {
return {
GlobalState,
@@ -1493,6 +1786,9 @@ export default {
translatorLanguages: [],
propagationNodeStatus: null,
propagationStatusInterval: null,
showTelemetryInChat: false,
isTelemetryHistoryModalOpen: false,
};
},
computed: {
@@ -1605,7 +1901,15 @@ export default {
chatItem.lxmf_message.source_hash === this.selectedPeer.destination_hash;
const isToSelectedPeer =
chatItem.lxmf_message.destination_hash === this.selectedPeer.destination_hash;
return isFromSelectedPeer || isToSelectedPeer;
if (!(isFromSelectedPeer || isToSelectedPeer)) return false;
// filter telemetry if disabled
if (!this.showTelemetryInChat && this.isTelemetryOnly(chatItem.lxmf_message)) {
return false;
}
return true;
}
return false;
@@ -1615,6 +1919,24 @@ export default {
// no peer, so no chat items!
return [];
},
selectedPeerTelemetryItems() {
if (!this.selectedPeer) return [];
return this.chatItems
.filter((chatItem) => {
if (chatItem.type === "lxmf_message") {
const isFromSelectedPeer =
chatItem.lxmf_message.source_hash === this.selectedPeer.destination_hash;
const isToSelectedPeer =
chatItem.lxmf_message.destination_hash === this.selectedPeer.destination_hash;
if (!(isFromSelectedPeer || isToSelectedPeer)) return false;
return this.isTelemetryOnly(chatItem.lxmf_message);
}
return false;
})
.reverse();
},
selectedPeerChatItemsReversed() {
// ensure a copy of the array is returned in reverse order
return this.selectedPeerChatItems.map((message) => message).reverse();
@@ -1631,6 +1953,31 @@ export default {
(item) => item.is_outbound && ["failed", "cancelled"].includes(item.lxmf_message?.state)
);
},
telemetryBatteryHistory() {
return this.selectedPeerTelemetryItems
.filter((item) => item.lxmf_message.fields?.telemetry?.battery)
.map((item) => ({
x: item.lxmf_message.timestamp,
y: item.lxmf_message.fields.telemetry.battery.charge_percent,
}))
.sort((a, b) => a.x - b.x);
},
batterySparklinePath() {
const history = this.telemetryBatteryHistory;
if (history.length < 2) return "";
const minX = history[0].x;
const maxX = history[history.length - 1].x;
const rangeX = maxX - minX || 1;
return history
.map((p, i) => {
const x = ((p.x - minX) / rangeX) * 100;
const y = 100 - p.y; // SVG y is top-down
return `${i === 0 ? "M" : "L"} ${x} ${y}`;
})
.join(" ");
},
},
watch: {
selectedPeer: {
@@ -1695,6 +2042,26 @@ export default {
}, 2000);
},
methods: {
renderMarkdown(text) {
return MarkdownRenderer.render(text);
},
handleMessageClick(event) {
const nomadnetLink = event.target.closest(".nomadnet-link");
if (nomadnetLink) {
event.preventDefault();
const url = nomadnetLink.getAttribute("data-nomadnet-url");
if (url) {
const [hash, ...pathParts] = url.split(":");
const path = pathParts.join(":");
const routeName = this.$route.meta.isPopout ? "nomadnetwork-popout" : "nomadnetwork";
this.$router.push({
name: routeName,
params: { destinationHash: hash },
query: { path: path },
});
}
}
},
async updatePropagationNodeStatus() {
try {
const response = await window.axios.get("/api/v1/lxmf/propagation-node/status");
@@ -2048,13 +2415,7 @@ export default {
break;
}
case "lxm.ingest_uri.result": {
if (json.status === "success") {
ToastUtils.success(json.message);
} else if (json.status === "error") {
ToastUtils.error(json.message);
} else {
ToastUtils.warning(json.message);
}
// Handled in App.vue or MessagesPage.vue
break;
}
}
@@ -2661,6 +3022,7 @@ export default {
fileAttachmentsTotalSize += file.size;
fileAttachments.push({
file_name: file.name,
file_size: file.size,
file_bytes: Utils.arrayBufferToBase64(await file.arrayBuffer()),
});
}
@@ -2675,6 +3037,7 @@ export default {
imageTotalSize += image.size;
images.push({
image_type: image.type.replace("image/", ""),
image_size: image.size,
image_bytes: Utils.arrayBufferToBase64(await image.arrayBuffer()),
name: image.name,
});
@@ -2687,6 +3050,7 @@ export default {
audioTotalSize = this.newMessageAudio.size;
fields["audio"] = {
audio_mode: this.newMessageAudio.audio_mode,
audio_size: this.newMessageAudio.size,
audio_bytes: Utils.arrayBufferToBase64(await this.newMessageAudio.audio_blob.arrayBuffer()),
};
}
@@ -2882,27 +3246,59 @@ export default {
}
},
async shareLocation() {
const toastKey = "location_share";
try {
if (this.config?.location_source === "manual") {
const lat = parseFloat(this.config.location_manual_lat);
const lon = parseFloat(this.config.location_manual_lon);
const alt = parseFloat(this.config.location_manual_alt);
if (isNaN(lat) || isNaN(lon)) {
ToastUtils.error("Invalid manual coordinates in settings", 5000, toastKey);
return;
}
this.newMessageTelemetry = {
latitude: lat,
longitude: lon,
altitude: isNaN(alt) ? 0 : alt,
speed: 0,
bearing: 0,
accuracy: 0,
last_update: Math.floor(Date.now() / 1000),
};
this.sendMessage();
ToastUtils.success(this.$t("messages.location_sent"), 3000, toastKey);
return;
}
if (!navigator.geolocation) {
DialogUtils.alert(this.$t("map.geolocation_not_supported"));
return;
}
ToastUtils.loading(this.$t("messages.fetching_location"), 0, toastKey);
navigator.geolocation.getCurrentPosition(
(position) => {
this.newMessageTelemetry = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
altitude: position.coords.altitude || 0,
speed: (position.coords.speed || 0) * 3.6, // m/s to km/h to match Sideband
speed: (position.coords.speed || 0) * 3.6, // m/s to km/h
bearing: position.coords.heading || 0,
accuracy: position.coords.accuracy || 0,
last_update: Math.floor(Date.now() / 1000),
};
this.sendMessage();
ToastUtils.success(this.$t("messages.location_sent"), 3000, toastKey);
},
(error) => {
DialogUtils.alert(`Failed to get location: ${error.message}`);
ToastUtils.error(
`Failed to get location: ${error.message}. Try setting location manually in Settings.`,
5000,
toastKey
);
},
{
enableHighAccuracy: true,
@@ -2912,6 +3308,7 @@ export default {
);
} catch (e) {
console.log(e);
ToastUtils.error(`Error: ${e.message}`, 5000, toastKey);
}
},
async requestLocation() {
@@ -2924,9 +3321,7 @@ export default {
destination_hash: this.selectedPeer.destination_hash,
content: "",
fields: {
commands: [
{ "0x01": Math.floor(Date.now() / 1000) }, // Sideband TELEMETRY_REQUEST
],
commands: [{ "0x01": Math.floor(Date.now() / 1000) }],
},
},
});
@@ -2948,6 +3343,32 @@ export default {
},
});
},
isTelemetryOnly(msg) {
const hasContent = msg.content && msg.content.trim() !== "";
const hasAttachments = msg.fields?.image || msg.fields?.audio || msg.fields?.file_attachments;
const hasTelemetry = msg.fields?.telemetry || msg.fields?.telemetry_stream;
const hasCommands = msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"]);
return !hasContent && !hasAttachments && (hasTelemetry || hasCommands);
},
async toggleTracking() {
if (!this.selectedPeer) return;
const hash = this.selectedPeer.destination_hash;
try {
const response = await window.axios.post(`/api/v1/telemetry/tracking/${hash}/toggle`, {
is_tracking: !this.selectedPeer.is_tracking,
});
// Emit event to parent to update peer status
this.$emit("update-peer-tracking", {
destination_hash: hash,
is_tracking: response.data.is_tracking,
});
ToastUtils.success(response.data.is_tracking ? "Live tracking enabled" : "Live tracking disabled");
} catch (e) {
console.error("Failed to toggle tracking", e);
ToastUtils.error("Failed to update tracking status");
}
},
formatTimeAgo: function (datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
@@ -3387,4 +3808,29 @@ export default {
.dark .audio-controls-dark {
filter: invert(1) hue-rotate(180deg);
}
.markdown-content :deep(p) {
margin: 0.5rem 0;
}
.markdown-content :deep(strong) {
font-weight: 700;
}
.markdown-content :deep(em) {
font-style: italic;
}
.markdown-content :deep(pre) {
margin: 0.75rem 0;
}
.markdown-content :deep(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.markdown-content :deep(h1),
.markdown-content :deep(h2),
.markdown-content :deep(h3) {
line-height: 1.2;
}
</style>

View File

@@ -50,6 +50,7 @@
:selected-peer="selectedPeer"
:conversations="conversations"
@update:selected-peer="onPeerClick"
@update-peer-tracking="onUpdatePeerTracking"
@close="onCloseConversationViewer"
@reload-conversations="getConversations"
/>
@@ -293,16 +294,21 @@ export default {
await this.getConversations();
break;
}
case "lxmf.telemetry": {
// update tracking status if peer matches
const destHash = json.destination_hash;
if (this.peers[destHash]) {
this.peers[destHash].is_tracking = json.is_tracking;
}
if (this.selectedPeer && this.selectedPeer.destination_hash === destHash) {
this.selectedPeer.is_tracking = json.is_tracking;
}
break;
}
case "lxm.ingest_uri.result": {
if (json.status === "success") {
ToastUtils.success(json.message);
this.ingestUri = "";
await this.getConversations();
} else if (json.status === "error") {
ToastUtils.error(json.message);
} else if (json.status === "warning") {
ToastUtils.warning(json.message);
} else {
ToastUtils.info(json.message);
}
break;
}
@@ -398,6 +404,7 @@ export default {
contact_image: conversation.contact_image ?? existingPeer.contact_image,
lxmf_user_icon: conversation.lxmf_user_icon ?? existingPeer.lxmf_user_icon,
updated_at: conversation.updated_at ?? existingPeer.updated_at,
is_tracking: conversation.is_tracking ?? existingPeer.is_tracking,
};
}
@@ -561,6 +568,14 @@ export default {
const existing = this.peers[announce.destination_hash] || {};
this.peers[announce.destination_hash] = { ...existing, ...announce };
},
onUpdatePeerTracking({ destination_hash, is_tracking }) {
if (this.peers[destination_hash]) {
this.peers[destination_hash].is_tracking = is_tracking;
}
if (this.selectedPeer && this.selectedPeer.destination_hash === destination_hash) {
this.selectedPeer.is_tracking = is_tracking;
}
},
onPeerClick: function (peer) {
// update selected peer
this.selectedPeer = peer;

View File

@@ -390,27 +390,37 @@
</div>
<div class="text-gray-600 dark:text-gray-400 text-xs mt-0.5 truncate">
{{
conversation.latest_message_preview ??
conversation.latest_message_title ??
"No messages yet"
stripMarkdown(
conversation.latest_message_preview ?? conversation.latest_message_title
) ?? "No messages yet"
}}
</div>
</div>
<div class="flex items-center space-x-1">
<div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4" />
<div class="flex flex-col items-center justify-between ml-1 py-1 shrink-0">
<div class="flex items-center space-x-1">
<div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4" />
</div>
<div
v-if="
conversation.is_unread &&
conversation.destination_hash !== selectedDestinationHash
"
class="my-auto ml-1"
>
<div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
</div>
<div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
</div>
</div>
<div
v-if="
conversation.is_unread && conversation.destination_hash !== selectedDestinationHash
"
class="my-auto ml-1"
<button
type="button"
class="p-1 opacity-0 group-hover:opacity-100 hover:bg-gray-200 dark:hover:bg-zinc-800 rounded-lg transition-all"
@click.stop="onRightClick($event, conversation.destination_hash)"
>
<div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
</div>
<div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
</div>
<MaterialDesignIcon icon-name="dots-vertical" class="size-4 text-gray-400" />
</button>
</div>
</div>
@@ -430,6 +440,31 @@
<span class="font-medium">Mark as Read</span>
</button>
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
<button
v-if="GlobalState.config.telemetry_enabled"
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="toggleTelemetryTrust(contextMenu.targetHash)"
>
<MaterialDesignIcon
:icon-name="
contextMenu.targetContact?.is_telemetry_trusted ? 'shield-check' : 'shield-outline'
"
:class="
contextMenu.targetContact?.is_telemetry_trusted ? 'text-blue-500' : 'text-gray-400'
"
class="size-4"
/>
<span class="font-medium">{{
contextMenu.targetContact?.is_telemetry_trusted
? "Revoke Telemetry Trust"
: "Trust for Telemetry"
}}</span>
</button>
<div
v-if="GlobalState.config.telemetry_enabled"
class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"
></div>
<div
class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
>
@@ -634,6 +669,8 @@ import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import GlobalState from "../../js/GlobalState";
import GlobalEmitter from "../../js/GlobalEmitter";
import MarkdownRenderer from "../../js/MarkdownRenderer";
export default {
name: "MessagesSidebar",
@@ -749,6 +786,7 @@ export default {
x: 0,
y: 0,
targetHash: null,
targetContact: null,
},
draggedHash: null,
dragOverFolderId: null,
@@ -813,7 +851,35 @@ export default {
}
},
},
mounted() {
// listen for contact updates
GlobalEmitter.on("contact-updated", this.onContactUpdated);
},
unmounted() {
GlobalEmitter.off("contact-updated", this.onContactUpdated);
},
methods: {
onContactUpdated(data) {
// update local contact info if context menu is showing this peer
if (this.contextMenu.show && this.contextMenu.targetHash === data.remote_identity_hash) {
this.fetchContactForContextMenu(data.remote_identity_hash);
}
},
async fetchContactForContextMenu(hash) {
try {
const response = await window.axios.get(`/api/v1/telephone/contacts/check/${hash}`);
if (response.data.is_contact) {
this.contextMenu.targetContact = response.data.contact;
} else {
this.contextMenu.targetContact = null;
}
} catch (e) {
console.error("Failed to fetch contact for context menu", e);
}
},
stripMarkdown(text) {
return MarkdownRenderer.strip(text);
},
toggleSelectionMode() {
this.selectionMode = !this.selectionMode;
if (!this.selectionMode) {
@@ -834,7 +900,7 @@ export default {
this.selectedHashes.add(hash);
}
},
onRightClick(event, hash) {
async onRightClick(event, hash) {
event.preventDefault();
if (this.selectionMode && !this.selectedHashes.has(hash)) {
this.selectedHashes.add(hash);
@@ -843,6 +909,10 @@ export default {
this.contextMenu.y = event.clientY;
this.contextMenu.targetHash = hash;
this.contextMenu.show = true;
this.contextMenu.targetContact = null;
// fetch contact info for trust status
await this.fetchContactForContextMenu(hash);
},
onFolderContextMenu(event) {
event.preventDefault();
@@ -897,6 +967,38 @@ export default {
this.$emit("delete-folder", folder.id);
}
},
async toggleTelemetryTrust(hash) {
const contact = this.contextMenu.targetContact;
const newStatus = !contact?.is_telemetry_trusted;
try {
if (!contact) {
// find display name from conversations
const conv = this.conversations.find((c) => c.destination_hash === hash);
await window.axios.post("/api/v1/telephone/contacts", {
name: conv?.display_name || hash.substring(0, 8),
remote_identity_hash: hash,
is_telemetry_trusted: true,
});
} else {
await window.axios.patch(`/api/v1/telephone/contacts/${contact.id}`, {
is_telemetry_trusted: newStatus,
});
}
GlobalEmitter.emit("contact-updated", {
remote_identity_hash: hash,
is_telemetry_trusted: newStatus,
});
this.contextMenu.show = false;
DialogUtils.alert(
newStatus
? this.$t("app.telemetry_trust_granted_alert")
: this.$t("app.telemetry_trust_revoked_alert")
);
} catch (e) {
DialogUtils.alert(this.$t("app.telemetry_trust_failed"));
console.error(e);
}
},
bulkMarkAsRead() {
const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
this.$emit("bulk-mark-as-read", hashes);

View File

@@ -277,38 +277,37 @@ export default {
}
},
async sendPaperMessage() {
const canvas = this.$refs.qrcode;
if (!canvas || !this.recipientHash || !this.uri) return;
if (!this.recipientHash || !this.uri) return;
try {
this.isSending = true;
// get data url from canvas (format: data:image/png;base64,iVBORw0KG...)
const dataUrl = canvas.toDataURL("image/png");
// extract base64 data by removing the prefix
const imageBytes = dataUrl.split(",")[1];
// build lxmf message
const lxmf_message = {
destination_hash: this.recipientHash,
content: this.uri,
fields: {
image: {
image_type: "png",
image_bytes: imageBytes,
name: "qrcode.png",
},
},
content: `Please scan the attached Paper Message or manually ingest this URI: ${this.uri}`,
fields: {},
};
// get data url from canvas if available
const canvas = this.$refs.qrcode;
if (canvas) {
const dataUrl = canvas.toDataURL("image/png");
const imageBytes = dataUrl.split(",")[1];
lxmf_message.fields.image = {
image_type: "png",
image_bytes: imageBytes,
name: "paper_message_qr.png",
};
}
// send message
const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
delivery_method: "opportunistic",
lxmf_message: lxmf_message,
});
if (response.data.status === "success") {
if (response.data.lxmf_message) {
ToastUtils.success(this.$t("messages.paper_message_sent"));
this.close();
} else {

View File

@@ -171,6 +171,14 @@ export default {
beforeUnmount() {
this.stop();
},
mounted() {
if (this.$route.query.hash) {
this.destinationHash = this.$route.query.hash;
if (this.$route.query.autostart === "1" || this.$route.query.autostart === "true") {
this.start();
}
}
},
methods: {
async start() {
// do nothing if already running

View File

@@ -28,14 +28,17 @@
<!-- eslint-disable vue/no-v-html -->
<p
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
@click="handleMessageClick"
v-html="renderMarkdown($t('rncp.step_1'))"
></p>
<p
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
@click="handleMessageClick"
v-html="renderMarkdown($t('rncp.step_2'))"
></p>
<p
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
@click="handleMessageClick"
v-html="renderMarkdown($t('rncp.step_3'))"
></p>
<!-- eslint-enable vue/no-v-html -->
@@ -336,6 +339,7 @@
import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
import MarkdownRenderer from "../../js/MarkdownRenderer";
export default {
name: "RNCPPage",
@@ -520,8 +524,23 @@ export default {
this.listenResult = null;
},
renderMarkdown(text) {
if (!text) return "";
return text.replace(/\*\*(.*?)\*\*/g, "<b>$1</b>");
return MarkdownRenderer.render(text);
},
handleMessageClick(event) {
const nomadnetLink = event.target.closest(".nomadnet-link");
if (nomadnetLink) {
event.preventDefault();
const url = nomadnetLink.getAttribute("data-nomadnet-url");
if (url) {
const [hash, ...pathParts] = url.split(":");
const path = pathParts.join(":");
this.$router.push({
name: "nomadnetwork",
params: { destinationHash: hash },
query: { path: path },
});
}
}
},
},
};

View File

@@ -106,7 +106,7 @@
<input
v-model="searchQuery"
type="text"
class="w-full bg-white/80 dark:bg-zinc-900/80 border border-gray-200 dark:border-zinc-800 rounded-2xl py-3 pl-12 pr-4 text-sm focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all shadow-sm"
class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl py-3 pl-12 pr-4 text-sm focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all shadow-sm"
:placeholder="$t('app.search_settings') || 'Search settings...'"
/>
<button
@@ -687,6 +687,165 @@
</div>
</section>
<!-- Location -->
<section v-show="matchesSearch(...sectionKeywords.location)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Privacy</div>
<h2>Location</h2>
<p>Manage how your location is shared.</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Location Source</div>
<select
v-model="config.location_source"
class="input-field"
@change="
updateConfig({ location_source: config.location_source }, 'location_source')
"
>
<option value="browser">Automatic (Browser)</option>
<option value="manual">Manual</option>
</select>
<div
v-if="config.location_source === 'browser'"
class="text-xs text-gray-600 dark:text-gray-400"
>
Uses your browser's geolocation API. Note: In the desktop app, this can use Google
services, which is blocked by CORS so you would need to specifically allow it.
</div>
<div
v-if="config.location_source === 'manual'"
class="text-xs text-gray-600 dark:text-gray-400"
>
Use manually entered coordinates for maximum privacy.
</div>
</div>
<div
v-if="config.location_source === 'manual'"
class="grid grid-cols-1 sm:grid-cols-3 gap-4"
>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.location_manual_lat") }}
</div>
<input
v-model="config.location_manual_lat"
type="text"
class="input-field"
placeholder="0.0"
@input="
updateConfig(
{ location_manual_lat: config.location_manual_lat },
'location_manual_lat'
)
"
/>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.location_manual_lon") }}
</div>
<input
v-model="config.location_manual_lon"
type="text"
class="input-field"
placeholder="0.0"
@input="
updateConfig(
{ location_manual_lon: config.location_manual_lon },
'location_manual_lon'
)
"
/>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.location_manual_alt") }}
</div>
<input
v-model="config.location_manual_alt"
type="text"
class="input-field"
placeholder="0.0"
@input="
updateConfig(
{ location_manual_alt: config.location_manual_alt },
'location_manual_alt'
)
"
/>
</div>
</div>
<div class="pt-4 border-t border-gray-100 dark:border-zinc-800 space-y-4">
<label class="setting-toggle">
<Toggle
id="telemetry-enabled"
v-model="config.telemetry_enabled"
@update:model-value="
updateConfig(
{ telemetry_enabled: config.telemetry_enabled },
'telemetry_enabled'
)
"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{ $t("app.telemetry_enabled") }}</span>
<span class="setting-toggle__description">{{
$t("app.telemetry_description")
}}</span>
</span>
</label>
</div>
<div
v-if="config.telemetry_enabled"
class="pt-4 border-t border-gray-100 dark:border-zinc-800 space-y-4"
>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.telemetry_trusted_peers") }}
</div>
<div v-if="trustedTelemetryPeers.length === 0" class="text-xs text-gray-500 italic">
{{ $t("app.telemetry_no_trusted_peers") }}
</div>
<div v-else class="space-y-2">
<div
v-for="peer in trustedTelemetryPeers"
:key="peer.id"
class="flex items-center justify-between p-2 rounded-xl bg-gray-50 dark:bg-zinc-800 border border-gray-100 dark:border-zinc-700"
>
<div class="flex items-center gap-3">
<div
class="size-8 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center"
>
<MaterialDesignIcon icon-name="account" class="size-5" />
</div>
<div class="min-w-0">
<div class="text-sm font-bold text-gray-900 dark:text-white truncate">
{{ peer.name }}
</div>
<div class="text-[10px] text-gray-500 font-mono truncate">
{{ peer.remote_identity_hash }}
</div>
</div>
</div>
<button
class="p-2 text-gray-400 hover:text-red-500 transition-colors"
:title="$t('app.telemetry_revoke_trust')"
@click="revokeTelemetryTrust(peer)"
>
<MaterialDesignIcon icon-name="shield-off-outline" class="size-5" />
</button>
</div>
</div>
</div>
</div>
</section>
<!-- Language -->
<section
v-show="
@@ -713,6 +872,7 @@
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="ru">Русский</option>
<option value="it">Italiano</option>
</select>
</div>
</section>
@@ -959,6 +1119,98 @@
</div>
</section>
<!-- Content Security Policy (CSP) -->
<section v-show="matchesSearch(...sectionKeywords.csp)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Security</div>
<h2>{{ $t("app.csp_settings") }}</h2>
<p>{{ $t("app.csp_description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.csp_extra_connect_src") }}
</div>
<input
v-model="config.csp_extra_connect_src"
type="text"
class="input-field font-mono text-xs"
placeholder="https://api.example.com, wss://socket.example.com"
@input="onCspConfigChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("app.csp_extra_connect_src_description") }}
</div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.csp_extra_img_src") }}
</div>
<input
v-model="config.csp_extra_img_src"
type="text"
class="input-field font-mono text-xs"
placeholder="https://tiles.example.com, https://cdn.example.com"
@input="onCspConfigChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("app.csp_extra_img_src_description") }}
</div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.csp_extra_frame_src") }}
</div>
<input
v-model="config.csp_extra_frame_src"
type="text"
class="input-field font-mono text-xs"
placeholder="https://video.example.com"
@input="onCspConfigChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("app.csp_extra_frame_src_description") }}
</div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.csp_extra_script_src") }}
</div>
<input
v-model="config.csp_extra_script_src"
type="text"
class="input-field font-mono text-xs"
placeholder="https://scripts.example.com"
@input="onCspConfigChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("app.csp_extra_script_src_description") }}
</div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.csp_extra_style_src") }}
</div>
<input
v-model="config.csp_extra_style_src"
type="text"
class="input-field font-mono text-xs"
placeholder="https://fonts.example.com"
@input="onCspConfigChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("app.csp_extra_style_src_description") }}
</div>
</div>
</div>
</section>
<!-- Messages -->
<section v-show="matchesSearch(...sectionKeywords.messages)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
@@ -1256,13 +1508,24 @@ export default {
message_icon_size: 28,
telephone_tone_generator_enabled: true,
telephone_tone_generator_volume: 50,
location_source: "browser",
location_manual_lat: "0.0",
location_manual_lon: "0.0",
location_manual_alt: "0.0",
telemetry_enabled: false,
gitea_base_url: "https://git.quad4.io",
docs_download_urls: "",
csp_extra_connect_src: "",
csp_extra_img_src: "",
csp_extra_frame_src: "",
csp_extra_script_src: "",
csp_extra_style_src: "",
},
saveTimeouts: {},
shortcuts: [],
reloadingRns: false,
searchQuery: "",
trustedTelemetryPeers: [],
sectionKeywords: {
banishment: [
"Visuals",
@@ -1305,6 +1568,18 @@ export default {
],
archiver: ["Browsing", "Page Archiver", "archiver", "archive", "versions", "storage", "flush"],
crawler: ["Discovery", "Smart Crawler", "crawler", "crawl", "retries", "delay", "concurrent"],
csp: [
"Security",
"app.csp_settings",
"app.csp_description",
"app.csp_extra_connect_src",
"app.csp_extra_img_src",
"app.csp_extra_frame_src",
"app.csp_extra_script_src",
"app.csp_extra_style_src",
"CSP",
"Content Security Policy",
],
appearance: [
"Personalise",
"app.appearance",
@@ -1374,6 +1649,17 @@ export default {
"app.propagation_stamp_cost",
"app.propagation_stamp_description",
],
location: [
"Location",
"GPS",
"Privacy",
"manual",
"latitude",
"longitude",
"altitude",
"telemetry",
"trusted peers",
],
shortcuts: ["Keyboard Shortcuts", "actions", "workflow"],
},
};
@@ -1391,6 +1677,10 @@ export default {
lxmf_address_hash: "",
theme: "dark",
is_transport_enabled: false,
location_source: "browser",
location_manual_lat: "0.0",
location_manual_lon: "0.0",
location_manual_alt: "0.0",
};
}
return this.config;
@@ -1419,8 +1709,29 @@ export default {
WebSocketConnection.on("message", this.onWebsocketMessage);
this.getConfig();
this.getTrustedTelemetryPeers();
},
methods: {
async getTrustedTelemetryPeers() {
try {
const response = await window.axios.get("/api/v1/telemetry/trusted-peers");
this.trustedTelemetryPeers = response.data.trusted_peers;
} catch (e) {
console.error("Failed to fetch trusted telemetry peers", e);
}
},
async revokeTelemetryTrust(peer) {
try {
await window.axios.patch(`/api/v1/telephone/contacts/${peer.id}`, {
is_telemetry_trusted: false,
});
this.getTrustedTelemetryPeers();
ToastUtils.success(this.$t("app.telemetry_trust_revoked", { name: peer.name }));
} catch (e) {
ToastUtils.error("Failed to revoke telemetry trust");
console.error(e);
}
},
matchesSearch(...texts) {
if (!this.searchQuery) return true;
const query = this.searchQuery.toLowerCase();
@@ -1787,6 +2098,21 @@ export default {
);
}, 1000);
},
async onCspConfigChange() {
if (this.saveTimeouts.csp) clearTimeout(this.saveTimeouts.csp);
this.saveTimeouts.csp = setTimeout(async () => {
await this.updateConfig(
{
csp_extra_connect_src: this.config.csp_extra_connect_src,
csp_extra_img_src: this.config.csp_extra_img_src,
csp_extra_frame_src: this.config.csp_extra_frame_src,
csp_extra_script_src: this.config.csp_extra_script_src,
csp_extra_style_src: this.config.csp_extra_style_src,
},
"csp_settings"
);
}, 1000);
},
async onBackupConfigChange() {
if (this.saveTimeouts.backup) clearTimeout(this.saveTimeouts.backup);
this.saveTimeouts.backup = setTimeout(async () => {

View File

@@ -267,12 +267,7 @@ export default {
}
} else if (json.type === "lxm.ingest_uri.result") {
if (json.status === "success") {
ToastUtils.success(json.message);
this.ingestUri = "";
} else if (json.status === "error") {
ToastUtils.error(json.message);
} else {
ToastUtils.warning(json.message);
}
}
},
@@ -384,7 +379,7 @@ export default {
lxmf_message: lxmf_message,
});
if (response.data.status === "success") {
if (response.data.lxmf_message) {
ToastUtils.success(this.$t("messages.paper_message_sent"));
this.generatedUri = null;
this.destinationHash = "";

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

View File

@@ -4,30 +4,41 @@
>
<div class="flex-1 overflow-y-auto w-full">
<div class="space-y-4 p-4 md:p-6 lg:p-8 w-full">
<div class="glass-card space-y-3">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
<div class="glass-card flex flex-col md:flex-row md:items-center justify-between gap-6 p-6 md:p-8">
<div class="space-y-3 flex-1 min-w-0">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
</div>
<div class="text-3xl font-black text-gray-900 dark:text-white tracking-tight">
{{ $t("tools.power_tools") }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed max-w-xl">
{{ $t("tools.diagnostics_description") }}
</div>
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
{{ $t("tools.power_tools") }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $t("tools.diagnostics_description") }}
</div>
</div>
<div class="glass-card">
<div class="relative">
<MaterialDesignIcon
icon-name="magnify"
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
/>
<input
v-model="searchQuery"
type="text"
:placeholder="$t('common.search')"
class="w-full pl-10 pr-4 py-3 bg-white/50 dark:bg-zinc-900/50 border border-gray-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
/>
<div class="w-full md:w-80 shrink-0">
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<MaterialDesignIcon
icon-name="magnify"
class="size-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
/>
</div>
<input
v-model="searchQuery"
type="text"
:placeholder="$t('common.search')"
class="w-full pl-12 pr-10 py-3.5 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 shadow-sm transition-all text-sm"
/>
<button
v-if="searchQuery"
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
@click="searchQuery = ''"
>
<MaterialDesignIcon icon-name="close-circle" class="size-5" />
</button>
</div>
</div>
</div>
@@ -147,6 +158,14 @@ export default {
titleKey: "tools.rnpath.title",
descriptionKey: "tools.rnpath.description",
},
{
name: "rnpath-trace",
route: { name: "rnpath-trace" },
icon: "map-marker-path",
iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
titleKey: "tools.rnpath_trace.title",
descriptionKey: "tools.rnpath_trace.description",
},
{
name: "translator",
route: { name: "translator" },