feat(frontend): big updates (too many)
This commit is contained in:
@@ -4,34 +4,37 @@
|
||||
type="button"
|
||||
class="relative rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
:title="$t('app.language')"
|
||||
@click="toggleDropdown"
|
||||
@click.stop="toggleDropdown"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="translate" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isDropdownOpen"
|
||||
v-click-outside="closeDropdown"
|
||||
class="absolute right-0 mt-2 w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] overflow-hidden"
|
||||
>
|
||||
<div class="p-2">
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
type="button"
|
||||
class="w-full px-4 py-2 text-left rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors flex items-center justify-between"
|
||||
:class="{
|
||||
'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400':
|
||||
currentLanguage === lang.code,
|
||||
'text-gray-900 dark:text-zinc-100': currentLanguage !== lang.code,
|
||||
}"
|
||||
@click="selectLanguage(lang.code)"
|
||||
>
|
||||
<span class="font-medium">{{ lang.name }}</span>
|
||||
<MaterialDesignIcon v-if="currentLanguage === lang.code" icon-name="check" class="w-5 h-5" />
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isDropdownOpen"
|
||||
v-click-outside="closeDropdown"
|
||||
class="fixed w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] overflow-hidden"
|
||||
:style="dropdownStyle"
|
||||
>
|
||||
<div class="p-2">
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
type="button"
|
||||
class="w-full px-4 py-2 text-left rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors flex items-center justify-between"
|
||||
:class="{
|
||||
'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400':
|
||||
currentLanguage === lang.code,
|
||||
'text-gray-900 dark:text-zinc-100': currentLanguage !== lang.code,
|
||||
}"
|
||||
@click="selectLanguage(lang.code)"
|
||||
>
|
||||
<span class="font-medium">{{ lang.name }}</span>
|
||||
<MaterialDesignIcon v-if="currentLanguage === lang.code" icon-name="check" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -62,6 +65,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
isDropdownOpen: false,
|
||||
dropdownPosition: { top: 0, left: 0 },
|
||||
languages: [
|
||||
{ code: "en", name: "English" },
|
||||
{ code: "de", name: "Deutsch" },
|
||||
@@ -73,10 +77,27 @@ export default {
|
||||
currentLanguage() {
|
||||
return this.$i18n.locale;
|
||||
},
|
||||
dropdownStyle() {
|
||||
return {
|
||||
top: `${this.dropdownPosition.top}px`,
|
||||
left: `${this.dropdownPosition.left}px`,
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleDropdown() {
|
||||
toggleDropdown(event) {
|
||||
this.isDropdownOpen = !this.isDropdownOpen;
|
||||
if (this.isDropdownOpen) {
|
||||
this.updateDropdownPosition(event);
|
||||
}
|
||||
},
|
||||
updateDropdownPosition(event) {
|
||||
const button = event.currentTarget;
|
||||
const rect = button.getBoundingClientRect();
|
||||
this.dropdownPosition = {
|
||||
top: rect.bottom + 8,
|
||||
left: Math.max(8, rect.right - 192), // 192px is w-48
|
||||
};
|
||||
},
|
||||
closeDropdown() {
|
||||
this.isDropdownOpen = false;
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-[15%] rounded-full shrink-0 flex items-center justify-center"
|
||||
class="bg-gray-100 dark:bg-zinc-800 text-gray-400 dark:text-zinc-500 p-[15%] rounded-full shrink-0 flex items-center justify-center border border-gray-200 dark:border-zinc-700"
|
||||
:class="iconClass || 'size-6'"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-full h-full" />
|
||||
<MaterialDesignIcon icon-name="account" class="w-full h-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="relative rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
@click="toggleDropdown"
|
||||
@click.stop="toggleDropdown"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="bell" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
<span
|
||||
@@ -14,96 +14,103 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isDropdownOpen"
|
||||
v-click-outside="closeDropdown"
|
||||
class="absolute right-0 mt-2 w-80 sm:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[500px] overflow-hidden flex flex-col"
|
||||
>
|
||||
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div v-if="isLoading" class="p-8 text-center">
|
||||
<div class="inline-block animate-spin text-gray-400">
|
||||
<MaterialDesignIcon icon-name="refresh" class="w-6 h-6" />
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isDropdownOpen"
|
||||
v-click-outside="closeDropdown"
|
||||
class="fixed w-80 sm:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[500px] overflow-hidden flex flex-col"
|
||||
:style="dropdownStyle"
|
||||
>
|
||||
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading notifications...</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="notifications.length === 0" class="p-8 text-center">
|
||||
<MaterialDesignIcon
|
||||
icon-name="bell-off"
|
||||
class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">No new notifications</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
<button
|
||||
v-for="notification in notifications"
|
||||
:key="notification.destination_hash"
|
||||
type="button"
|
||||
class="w-full p-4 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
|
||||
@click="onNotificationClick(notification)"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
v-if="notification.lxmf_user_icon"
|
||||
class="p-2 rounded-lg"
|
||||
:style="{
|
||||
color: notification.lxmf_user_icon.foreground_colour,
|
||||
'background-color': notification.lxmf_user_icon.background_colour,
|
||||
}"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="notification.lxmf_user_icon.icon_name"
|
||||
class="w-6 h-6"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded-lg"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2 mb-1">
|
||||
<div
|
||||
class="font-semibold text-gray-900 dark:text-white truncate"
|
||||
:title="notification.custom_display_name ?? notification.display_name"
|
||||
>
|
||||
{{ notification.custom_display_name ?? notification.display_name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{{ formatTimeAgo(notification.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
|
||||
:title="notification.latest_message_preview ?? notification.content ?? 'No preview'"
|
||||
>
|
||||
{{ notification.latest_message_preview ?? notification.content ?? "No preview" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div v-if="isLoading" class="p-8 text-center">
|
||||
<div class="inline-block animate-spin text-gray-400">
|
||||
<MaterialDesignIcon icon-name="refresh" class="w-6 h-6" />
|
||||
</div>
|
||||
</button>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading notifications...</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="notifications.length === 0" class="p-8 text-center">
|
||||
<MaterialDesignIcon
|
||||
icon-name="bell-off"
|
||||
class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">No new notifications</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
<button
|
||||
v-for="notification in notifications"
|
||||
:key="notification.destination_hash"
|
||||
type="button"
|
||||
class="w-full p-4 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
|
||||
@click="onNotificationClick(notification)"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
v-if="notification.lxmf_user_icon"
|
||||
class="p-2 rounded-lg"
|
||||
:style="{
|
||||
color: notification.lxmf_user_icon.foreground_colour,
|
||||
'background-color': notification.lxmf_user_icon.background_colour,
|
||||
}"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="notification.lxmf_user_icon.icon_name"
|
||||
class="w-6 h-6"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded-lg"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2 mb-1">
|
||||
<div
|
||||
class="font-semibold text-gray-900 dark:text-white truncate"
|
||||
:title="notification.custom_display_name ?? notification.display_name"
|
||||
>
|
||||
{{ notification.custom_display_name ?? notification.display_name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{{ formatTimeAgo(notification.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
|
||||
:title="
|
||||
notification.latest_message_preview ?? notification.content ?? 'No preview'
|
||||
"
|
||||
>
|
||||
{{
|
||||
notification.latest_message_preview ?? notification.content ?? "No preview"
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -139,9 +146,17 @@ export default {
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
reloadInterval: null,
|
||||
dropdownPosition: { top: 0, left: 0 },
|
||||
};
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
dropdownStyle() {
|
||||
return {
|
||||
top: `${this.dropdownPosition.top}px`,
|
||||
left: `${this.dropdownPosition.left}px`,
|
||||
};
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.reloadInterval) {
|
||||
clearInterval(this.reloadInterval);
|
||||
@@ -158,13 +173,25 @@ export default {
|
||||
}, 5000);
|
||||
},
|
||||
methods: {
|
||||
async toggleDropdown() {
|
||||
async toggleDropdown(event) {
|
||||
this.isDropdownOpen = !this.isDropdownOpen;
|
||||
if (this.isDropdownOpen) {
|
||||
this.updateDropdownPosition(event);
|
||||
await this.loadNotifications();
|
||||
await this.markNotificationsAsViewed();
|
||||
}
|
||||
},
|
||||
updateDropdownPosition(event) {
|
||||
const button = event.currentTarget;
|
||||
const rect = button.getBoundingClientRect();
|
||||
const isMobile = window.innerWidth < 640;
|
||||
const dropdownWidth = isMobile ? 320 : 384; // 80 (320px) or 96 (384px)
|
||||
|
||||
this.dropdownPosition = {
|
||||
top: rect.bottom + 8,
|
||||
left: Math.max(16, rect.right - dropdownWidth),
|
||||
};
|
||||
},
|
||||
closeDropdown() {
|
||||
this.isDropdownOpen = false;
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,8 @@
|
||||
<MaterialDesignIcon icon-name="block-helper" class="size-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">Blocked</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Manage blocked users and nodes</p>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">Banished</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Manage Banished users and nodes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-6">
|
||||
<div v-if="isLoading && blockedItems.length === 0" class="flex flex-col items-center justify-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading blocked items...</p>
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading banished items...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -49,12 +49,12 @@
|
||||
<div class="p-4 bg-gray-100 dark:bg-zinc-800 rounded-full mb-4 text-gray-400 dark:text-zinc-600">
|
||||
<MaterialDesignIcon icon-name="check-circle" class="size-12" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">No blocked items</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">No banished items</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
|
||||
{{
|
||||
searchQuery
|
||||
? "No blocked items match your search."
|
||||
: "You haven't blocked any users or nodes yet."
|
||||
? "No banished items match your search."
|
||||
: "You haven't banished any users or nodes yet."
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -95,6 +95,13 @@
|
||||
>
|
||||
User
|
||||
</span>
|
||||
<span
|
||||
v-if="item.is_rns_blackholed"
|
||||
class="px-2 py-0.5 text-xs font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 rounded border border-zinc-200 dark:border-zinc-700"
|
||||
title="Blackholed at Reticulum transport layer"
|
||||
>
|
||||
RNS Blackhole
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-gray-500 dark:text-gray-400 font-mono break-all mt-1"
|
||||
@@ -104,8 +111,20 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Blocked {{ formatTimeAgo(item.created_at) }}
|
||||
<div v-if="item.created_at" class="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Banished {{ formatTimeAgo(item.created_at) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.rns_source"
|
||||
class="text-[10px] text-zinc-500 dark:text-zinc-500 font-mono truncate mb-1"
|
||||
>
|
||||
Source: {{ item.rns_source }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.rns_reason"
|
||||
class="text-xs italic text-zinc-500 dark:text-zinc-400 mb-2"
|
||||
>
|
||||
"{{ item.rns_reason }}"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +133,7 @@
|
||||
@click="onUnblock(item)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="check-circle" class="size-5" />
|
||||
<span>Unblock</span>
|
||||
<span>Lift Banishment</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,17 +156,31 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
blockedItems: [],
|
||||
reticulumBlackholedItems: [],
|
||||
isLoading: false,
|
||||
searchQuery: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
allBlockedItems() {
|
||||
// Combine local blocked items and reticulum blackholed items
|
||||
// Prioritize local items if they overlap
|
||||
const localHashes = new Set(this.blockedItems.map((i) => i.destination_hash));
|
||||
const combined = [...this.blockedItems];
|
||||
|
||||
for (const item of this.reticulumBlackholedItems) {
|
||||
if (!localHashes.has(item.destination_hash)) {
|
||||
combined.push(item);
|
||||
}
|
||||
}
|
||||
return combined;
|
||||
},
|
||||
filteredBlockedItems() {
|
||||
if (!this.searchQuery.trim()) {
|
||||
return this.blockedItems;
|
||||
return this.allBlockedItems;
|
||||
}
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
return this.blockedItems.filter((item) => {
|
||||
return this.allBlockedItems.filter((item) => {
|
||||
const matchesHash = item.destination_hash.toLowerCase().includes(query);
|
||||
const matchesDisplayName = (item.display_name || "").toLowerCase().includes(query);
|
||||
return matchesHash || matchesDisplayName;
|
||||
@@ -161,74 +194,70 @@ export default {
|
||||
async loadBlockedDestinations() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
// Load local blocked destinations
|
||||
const response = await window.axios.get("/api/v1/blocked-destinations");
|
||||
const blockedHashes = response.data.blocked_destinations || [];
|
||||
|
||||
const items = await Promise.all(
|
||||
blockedHashes.map(async (blocked) => {
|
||||
let displayName = "Unknown";
|
||||
let isNode = false;
|
||||
// Load Reticulum blackholed identities
|
||||
let reticulumBlackholed = {};
|
||||
try {
|
||||
const rnsResponse = await window.axios.get("/api/v1/reticulum/blackhole");
|
||||
reticulumBlackholed = rnsResponse.data.blackholed_identities || {};
|
||||
} catch (e) {
|
||||
console.error("Failed to load Reticulum blackhole", e);
|
||||
}
|
||||
|
||||
try {
|
||||
const nodeAnnounceResponse = await window.axios.get("/api/v1/announces", {
|
||||
params: {
|
||||
aspect: "nomadnetwork.node",
|
||||
identity_hash: blocked.destination_hash,
|
||||
include_blocked: true,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
const processItem = async (hash, data = {}) => {
|
||||
let displayName = "Unknown";
|
||||
let isNode = false;
|
||||
|
||||
if (nodeAnnounceResponse.data.announces && nodeAnnounceResponse.data.announces.length > 0) {
|
||||
const announce = nodeAnnounceResponse.data.announces[0];
|
||||
displayName = announce.display_name || "Unknown";
|
||||
isNode = true;
|
||||
} else {
|
||||
const announceResponse = await window.axios.get("/api/v1/announces", {
|
||||
params: {
|
||||
identity_hash: blocked.destination_hash,
|
||||
include_blocked: true,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const announceResponse = await window.axios.get("/api/v1/announces", {
|
||||
params: {
|
||||
identity_hash: hash,
|
||||
include_blocked: true,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (announceResponse.data.announces && announceResponse.data.announces.length > 0) {
|
||||
const announce = announceResponse.data.announces[0];
|
||||
displayName = announce.display_name || "Unknown";
|
||||
isNode = announce.aspect === "nomadnetwork.node";
|
||||
} else {
|
||||
const lxmfResponse = await window.axios.get("/api/v1/announces", {
|
||||
params: {
|
||||
destination_hash: blocked.destination_hash,
|
||||
include_blocked: true,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (lxmfResponse.data.announces && lxmfResponse.data.announces.length > 0) {
|
||||
const announce = lxmfResponse.data.announces[0];
|
||||
displayName = announce.display_name || "Unknown";
|
||||
isNode = announce.aspect === "nomadnetwork.node";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
if (announceResponse.data.announces && announceResponse.data.announces.length > 0) {
|
||||
const announce = announceResponse.data.announces[0];
|
||||
displayName = announce.display_name || "Unknown";
|
||||
isNode = announce.aspect === "nomadnetwork.node";
|
||||
}
|
||||
} catch {
|
||||
// ignore error
|
||||
}
|
||||
|
||||
return {
|
||||
destination_hash: blocked.destination_hash,
|
||||
display_name: displayName,
|
||||
created_at: blocked.created_at,
|
||||
is_node: isNode,
|
||||
};
|
||||
})
|
||||
return {
|
||||
destination_hash: hash,
|
||||
display_name: displayName,
|
||||
created_at: data.created_at || null,
|
||||
is_node: isNode,
|
||||
is_rns_blackholed: !!data.is_rns,
|
||||
rns_source: data.source || null,
|
||||
rns_reason: data.reason || null,
|
||||
rns_until: data.until || null,
|
||||
};
|
||||
};
|
||||
|
||||
const items = await Promise.all(
|
||||
blockedHashes.map((blocked) =>
|
||||
processItem(blocked.destination_hash, { created_at: blocked.created_at })
|
||||
)
|
||||
);
|
||||
|
||||
const rnsItems = await Promise.all(
|
||||
Object.entries(reticulumBlackholed).map(([hash, info]) =>
|
||||
processItem(hash, { ...info, is_rns: true })
|
||||
)
|
||||
);
|
||||
|
||||
this.blockedItems = items;
|
||||
this.reticulumBlackholedItems = rnsItems;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
ToastUtils.error("Failed to load blocked destinations");
|
||||
ToastUtils.error("Failed to load banished destinations");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
@@ -236,7 +265,7 @@ export default {
|
||||
async onUnblock(item) {
|
||||
if (
|
||||
!(await DialogUtils.confirm(
|
||||
`Are you sure you want to unblock ${item.display_name || item.destination_hash}?`
|
||||
`Are you sure you want to lift the banishment for ${item.display_name || item.destination_hash}?`
|
||||
))
|
||||
) {
|
||||
return;
|
||||
@@ -245,10 +274,10 @@ export default {
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/blocked-destinations/${item.destination_hash}`);
|
||||
await this.loadBlockedDestinations();
|
||||
ToastUtils.success("Unblocked successfully");
|
||||
ToastUtils.success("Banishment lifted successfully");
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
ToastUtils.error("Failed to unblock");
|
||||
ToastUtils.error("Failed to lift banishment");
|
||||
}
|
||||
},
|
||||
onSearchInput() {},
|
||||
|
||||
@@ -2380,15 +2380,15 @@ export default {
|
||||
}
|
||||
},
|
||||
async blockIdentity(hash) {
|
||||
if (!confirm(`Are you sure you want to block this identity?`)) return;
|
||||
if (!confirm(`Are you sure you want to banish this identity?`)) return;
|
||||
try {
|
||||
await window.axios.post("/api/v1/blocked-destinations", {
|
||||
destination_hash: hash,
|
||||
});
|
||||
ToastUtils.success("Identity blocked");
|
||||
ToastUtils.success("Identity banished");
|
||||
this.getHistory();
|
||||
} catch {
|
||||
ToastUtils.error("Failed to block identity");
|
||||
ToastUtils.error("Failed to banish identity");
|
||||
}
|
||||
},
|
||||
async getVoicemailStatus() {
|
||||
|
||||
@@ -1588,12 +1588,6 @@ export default {
|
||||
.glass-label {
|
||||
@apply mb-1 text-sm font-semibold text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
.primary-chip {
|
||||
@apply inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
|
||||
}
|
||||
.secondary-chip {
|
||||
@apply inline-flex items-center gap-x-2 rounded-full border border-gray-300 dark:border-zinc-700 px-3 py-1.5 text-xs font-semibold text-gray-700 dark:text-gray-100 bg-white/80 dark:bg-zinc-900/70 hover:border-blue-400;
|
||||
}
|
||||
.glass-field {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
@@ -78,12 +78,12 @@
|
||||
<!-- drawing toolbar -->
|
||||
<div class="absolute top-14 left-1/2 -translate-x-1/2 sm:top-2 z-20 flex flex-col gap-2 transform-gpu">
|
||||
<div
|
||||
class="bg-white/70 dark:bg-zinc-900/60 backdrop-blur-md rounded-2xl shadow-2xl overflow-hidden flex flex-row p-1 gap-1 border-0"
|
||||
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-row p-1 gap-1 border-0"
|
||||
>
|
||||
<button
|
||||
v-for="tool in drawingTools"
|
||||
:key="tool.type"
|
||||
class="p-2.5 rounded-xl transition-all hover:scale-110 active:scale-90"
|
||||
class="p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
|
||||
:class="[
|
||||
(drawType === tool.type && !isMeasuring) || (tool.type === 'Export' && isExportMode)
|
||||
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30'
|
||||
@@ -92,11 +92,11 @@
|
||||
:title="tool.type === 'Export' ? 'MBTiles exporter' : $t(`map.tool_${tool.type.toLowerCase()}`)"
|
||||
@click="tool.type === 'Export' ? toggleExportMode() : toggleDraw(tool.type)"
|
||||
>
|
||||
<v-icon :icon="'mdi-' + tool.icon" size="22"></v-icon>
|
||||
<v-icon :icon="'mdi-' + tool.icon" size="20"></v-icon>
|
||||
</button>
|
||||
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
|
||||
<button
|
||||
class="p-2.5 rounded-xl transition-all hover:scale-110 active:scale-90"
|
||||
class="p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
|
||||
:class="[
|
||||
isMeasuring
|
||||
? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/30'
|
||||
@@ -105,37 +105,37 @@
|
||||
:title="$t('map.tool_measure')"
|
||||
@click="toggleMeasure"
|
||||
>
|
||||
<v-icon icon="mdi-ruler" size="22"></v-icon>
|
||||
<v-icon icon="mdi-ruler" size="20"></v-icon>
|
||||
</button>
|
||||
<button
|
||||
class="p-2.5 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
|
||||
class="p-2 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
|
||||
:title="$t('map.tool_clear')"
|
||||
@click="clearDrawings"
|
||||
>
|
||||
<v-icon icon="mdi-trash-can-outline" size="22"></v-icon>
|
||||
<v-icon icon="mdi-trash-can-outline" size="20"></v-icon>
|
||||
</button>
|
||||
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
|
||||
<button
|
||||
class="p-2.5 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
|
||||
class="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
|
||||
:title="$t('map.save_drawing')"
|
||||
@click="showSaveDrawingModal = true"
|
||||
>
|
||||
<v-icon icon="mdi-content-save-outline" size="22"></v-icon>
|
||||
<v-icon icon="mdi-content-save-outline" size="20"></v-icon>
|
||||
</button>
|
||||
<button
|
||||
class="p-2.5 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
|
||||
class="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
|
||||
:title="$t('map.load_drawing')"
|
||||
@click="openLoadDrawingModal"
|
||||
>
|
||||
<v-icon icon="mdi-folder-open-outline" size="22"></v-icon>
|
||||
<v-icon icon="mdi-folder-open-outline" size="20"></v-icon>
|
||||
</button>
|
||||
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
|
||||
<button
|
||||
class="p-2.5 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-500 transition-all hover:scale-110 active:scale-90"
|
||||
class="p-2 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-500 transition-all hover:scale-110 active:scale-90"
|
||||
:title="$t('map.go_to_my_location')"
|
||||
@click="goToMyLocation"
|
||||
>
|
||||
<v-icon icon="mdi-crosshairs-gps" size="22"></v-icon>
|
||||
<v-icon icon="mdi-crosshairs-gps" size="20"></v-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,9 +147,7 @@
|
||||
class="absolute top-2 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 z-30"
|
||||
>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex items-center bg-white/70 dark:bg-zinc-900/60 backdrop-blur-md rounded-xl shadow-2xl border-0 ring-0"
|
||||
>
|
||||
<div class="flex items-center bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border-0 ring-0">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -237,8 +235,8 @@
|
||||
Edit Note
|
||||
</span>
|
||||
<button
|
||||
@click="closeNoteEditor"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
|
||||
@click="closeNoteEditor"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="size-4" />
|
||||
</button>
|
||||
@@ -250,15 +248,15 @@
|
||||
></textarea>
|
||||
<div class="flex justify-between mt-3">
|
||||
<button
|
||||
@click="deleteNote"
|
||||
class="px-3 py-1.5 text-xs font-semibold text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors flex items-center gap-1"
|
||||
@click="deleteNote"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="trash-can-outline" class="size-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
@click="saveNote"
|
||||
class="px-3 py-1.5 text-xs font-semibold bg-amber-500 text-white hover:bg-amber-600 rounded-lg shadow-sm transition-colors"
|
||||
@click="saveNote"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
@@ -922,8 +920,8 @@
|
||||
Edit Note
|
||||
</h3>
|
||||
<button
|
||||
@click="closeNoteEditor"
|
||||
class="p-2 text-gray-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors"
|
||||
@click="closeNoteEditor"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="size-5" />
|
||||
</button>
|
||||
@@ -938,15 +936,15 @@
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 dark:bg-zinc-800/50 flex justify-between gap-3">
|
||||
<button
|
||||
@click="deleteNote"
|
||||
class="flex-1 px-4 py-3 text-sm font-bold text-red-500 hover:bg-red-100 dark:hover:bg-red-900/20 rounded-xl transition-colors flex items-center justify-center gap-2"
|
||||
@click="deleteNote"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="trash-can-outline" class="size-5" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
@click="saveNote"
|
||||
class="flex-[2] px-4 py-3 text-sm font-bold bg-amber-500 text-white hover:bg-amber-600 rounded-xl shadow-lg shadow-amber-500/30 transition-colors"
|
||||
@click="saveNote"
|
||||
>
|
||||
Save Note
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="audio-waveform-player flex items-center gap-3 p-2 rounded-xl transition-all w-full min-w-[240px]"
|
||||
class="audio-waveform-player flex items-center gap-3 p-2 rounded-xl transition-all w-full min-w-0"
|
||||
:class="[
|
||||
isOutbound ? 'bg-white/10 text-white' : 'bg-gray-100 dark:bg-zinc-800/80 text-gray-800 dark:text-zinc-200',
|
||||
]"
|
||||
|
||||
@@ -28,11 +28,11 @@
|
||||
<div class="border-t">
|
||||
<DropDownMenuItem v-if="!isBlocked" @click="onBlockDestination">
|
||||
<MaterialDesignIcon icon-name="block-helper" class="size-5 text-red-500" />
|
||||
<span class="text-red-500">Block User</span>
|
||||
<span class="text-red-500">Banish User</span>
|
||||
</DropDownMenuItem>
|
||||
<DropDownMenuItem v-else @click="onUnblockDestination">
|
||||
<MaterialDesignIcon icon-name="check-circle" class="size-5 text-green-500" />
|
||||
<span class="text-green-500">Unblock User</span>
|
||||
<span class="text-green-500">Lift Banishment</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default {
|
||||
async onBlockDestination() {
|
||||
if (
|
||||
!(await DialogUtils.confirm(
|
||||
"Are you sure you want to block this user? They will not be able to send you messages or establish links."
|
||||
"Are you sure you want to banish this user? They will not be able to send you messages or establish links."
|
||||
))
|
||||
) {
|
||||
return;
|
||||
@@ -94,10 +94,10 @@ export default {
|
||||
destination_hash: this.peer.destination_hash,
|
||||
});
|
||||
GlobalEmitter.emit("block-status-changed");
|
||||
DialogUtils.alert("User blocked successfully");
|
||||
DialogUtils.alert("User banished successfully");
|
||||
this.$emit("block-status-changed");
|
||||
} catch (e) {
|
||||
DialogUtils.alert("Failed to block user");
|
||||
DialogUtils.alert("Failed to banish user");
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
@@ -105,10 +105,10 @@ export default {
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/blocked-destinations/${this.peer.destination_hash}`);
|
||||
GlobalEmitter.emit("block-status-changed");
|
||||
DialogUtils.alert("User unblocked successfully");
|
||||
DialogUtils.alert("Banishment lifted successfully");
|
||||
this.$emit("block-status-changed");
|
||||
} catch (e) {
|
||||
DialogUtils.alert("Failed to unblock user");
|
||||
DialogUtils.alert("Failed to lift banishment");
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<MaterialDesignIcon icon-name="tag-outline" class="size-4" />
|
||||
</div>
|
||||
<div
|
||||
class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-xs sm:max-w-sm text-base"
|
||||
class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-[120px] sm:max-w-sm text-base"
|
||||
:title="selectedPeer.custom_display_name ?? selectedPeer.display_name"
|
||||
>
|
||||
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
|
||||
@@ -193,16 +193,16 @@
|
||||
class="h-full overflow-y-scroll bg-gray-50/30 dark:bg-zinc-950/50"
|
||||
@scroll="onMessagesScroll"
|
||||
>
|
||||
<div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse px-4 py-6">
|
||||
<div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse px-4 py-6 min-w-0">
|
||||
<div
|
||||
v-for="chatItem of selectedPeerChatItemsReversed"
|
||||
:key="chatItem.lxmf_message.hash"
|
||||
class="flex flex-col max-w-[75%] sm:max-w-[65%] lg:max-w-[55%] mb-4 group"
|
||||
class="flex flex-col max-w-[85%] sm:max-w-[75%] lg:max-w-[65%] mb-4 group min-w-0"
|
||||
:class="{ 'ml-auto items-end': chatItem.is_outbound, 'mr-auto items-start': !chatItem.is_outbound }"
|
||||
>
|
||||
<!-- message content -->
|
||||
<div
|
||||
class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md"
|
||||
class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md min-w-0"
|
||||
:class="[
|
||||
['cancelled', 'failed'].includes(chatItem.lxmf_message.state)
|
||||
? 'bg-red-500 text-white shadow-sm'
|
||||
@@ -214,7 +214,7 @@
|
||||
]"
|
||||
@click="onChatItemClick(chatItem)"
|
||||
>
|
||||
<div class="w-full space-y-1 px-4 py-2.5">
|
||||
<div class="w-full space-y-1 px-4 py-2.5 min-w-0">
|
||||
<!-- spam badge -->
|
||||
<div
|
||||
v-if="chatItem.lxmf_message.is_spam"
|
||||
@@ -243,7 +243,7 @@
|
||||
<!-- content -->
|
||||
<div
|
||||
v-if="chatItem.lxmf_message.content"
|
||||
class="leading-relaxed whitespace-pre-wrap break-words"
|
||||
class="leading-relaxed whitespace-pre-wrap break-words [word-break:break-word] min-w-0"
|
||||
:style="{
|
||||
'font-family': 'inherit',
|
||||
'font-size': (config?.message_font_size || 14) + 'px',
|
||||
@@ -296,14 +296,14 @@
|
||||
|
||||
<!-- paper message auto-conversion -->
|
||||
<div
|
||||
v-if="getParsedItems(chatItem).paperMessage"
|
||||
class="flex flex-col gap-2 p-3 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800/30"
|
||||
v-if="getParsedItems(chatItem).paperMessage && !chatItem.is_outbound"
|
||||
class="flex flex-col gap-2 p-3 rounded-xl bg-emerald-50 dark:bg-black/60 border border-emerald-100 dark:border-zinc-700/50"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-emerald-700 dark:text-emerald-300">
|
||||
<div class="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
||||
<MaterialDesignIcon icon-name="qrcode-scan" class="size-5" />
|
||||
<span class="text-sm font-bold">Paper Message detected</span>
|
||||
</div>
|
||||
<p class="text-xs text-emerald-600/80 dark:text-emerald-400/80 leading-relaxed">
|
||||
<p class="text-xs text-emerald-600/80 dark:text-zinc-400 leading-relaxed">
|
||||
This message contains a signed LXMF URI that can be ingested into your
|
||||
conversations.
|
||||
</p>
|
||||
@@ -321,7 +321,7 @@
|
||||
<div v-if="chatItem.lxmf_message.fields?.image" class="relative group mt-1 -mx-1">
|
||||
<img
|
||||
:src="`/api/v1/lxmf-messages/attachment/${chatItem.lxmf_message.hash}/image`"
|
||||
class="w-full rounded-lg cursor-pointer transition-transform group-hover:scale-[1.01]"
|
||||
class="max-w-[240px] sm:max-w-xs w-full rounded-lg cursor-pointer transition-transform group-hover:scale-[1.01]"
|
||||
@click.stop="
|
||||
openImage(
|
||||
`/api/v1/lxmf-messages/attachment/${chatItem.lxmf_message.hash}/image`
|
||||
@@ -636,6 +636,7 @@
|
||||
<div
|
||||
v-for="(line, index) in getMessageInfoLines(chatItem.lxmf_message, chatItem.is_outbound)"
|
||||
:key="index"
|
||||
class="break-all"
|
||||
>
|
||||
{{ line }}
|
||||
</div>
|
||||
@@ -674,7 +675,7 @@
|
||||
class="w-full border-t border-gray-200/60 dark:border-zinc-800/60 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm px-3 sm:px-4 py-2.5"
|
||||
>
|
||||
<div class="w-full">
|
||||
<!-- blocked user notification -->
|
||||
<!-- banished user notification -->
|
||||
<div
|
||||
v-if="isSelectedPeerBlocked"
|
||||
class="mb-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg flex items-center gap-2"
|
||||
@@ -694,7 +695,7 @@
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm text-yellow-800 dark:text-yellow-200"
|
||||
>You have blocked this user. They cannot send you messages or establish links.</span
|
||||
>You have banished this user. They cannot send you messages or establish links.</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -914,13 +915,27 @@
|
||||
class="group cursor-pointer p-4 bg-white dark:bg-zinc-900/50 border border-gray-100 dark:border-zinc-800 rounded-2xl hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all duration-300 flex items-center gap-4"
|
||||
@click="$emit('update:selectedPeer', chat)"
|
||||
>
|
||||
<LxmfUserIcon
|
||||
:custom-image="chat.contact_image"
|
||||
:icon-name="chat.lxmf_user_icon ? chat.lxmf_user_icon.icon_name : ''"
|
||||
:icon-foreground-colour="chat.lxmf_user_icon ? chat.lxmf_user_icon.foreground_colour : ''"
|
||||
:icon-background-colour="chat.lxmf_user_icon ? chat.lxmf_user_icon.background_colour : ''"
|
||||
icon-class="size-10"
|
||||
/>
|
||||
<div class="flex-shrink-0">
|
||||
<LxmfUserIcon
|
||||
:custom-image="chat.contact_image"
|
||||
:icon-name="
|
||||
chat.lxmf_user_icon && chat.lxmf_user_icon.icon_name
|
||||
? chat.lxmf_user_icon.icon_name
|
||||
: 'account'
|
||||
"
|
||||
:icon-foreground-colour="
|
||||
chat.lxmf_user_icon && chat.lxmf_user_icon.foreground_colour
|
||||
? chat.lxmf_user_icon.foreground_colour
|
||||
: ''
|
||||
"
|
||||
:icon-background-colour="
|
||||
chat.lxmf_user_icon && chat.lxmf_user_icon.background_colour
|
||||
? chat.lxmf_user_icon.background_colour
|
||||
: ''
|
||||
"
|
||||
icon-class="size-12 sm:size-14"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-gray-900 dark:text-zinc-100 truncate">
|
||||
{{ chat.custom_display_name ?? chat.display_name }}
|
||||
@@ -984,12 +999,14 @@
|
||||
<PaperMessageModal
|
||||
v-if="isPaperMessageModalOpen"
|
||||
:message-hash="paperMessageHash"
|
||||
:recipient-hash="selectedPeer?.destination_hash"
|
||||
@close="isPaperMessageModalOpen = false"
|
||||
/>
|
||||
|
||||
<PaperMessageModal
|
||||
v-if="isPaperMessageResultModalOpen"
|
||||
:initial-uri="generatedPaperMessageUri"
|
||||
:recipient-hash="selectedPeer?.destination_hash"
|
||||
@close="
|
||||
isPaperMessageResultModalOpen = false;
|
||||
generatedPaperMessageUri = null;
|
||||
@@ -2763,7 +2780,16 @@ export default {
|
||||
@apply inline-flex items-center justify-center text-gray-500 dark:text-gray-300 hover:text-red-500;
|
||||
}
|
||||
.attachment-action-button {
|
||||
@apply inline-flex items-center gap-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition;
|
||||
@apply inline-flex items-center gap-1 rounded-full border border-gray-200 px-3 py-1.5 text-xs font-bold text-gray-700 bg-white shadow-sm transition-all !important;
|
||||
}
|
||||
.attachment-action-button:hover {
|
||||
@apply bg-gray-50 text-gray-900 border-blue-400 !important;
|
||||
}
|
||||
.dark .attachment-action-button {
|
||||
@apply border-zinc-700 text-zinc-100 bg-zinc-900 !important;
|
||||
}
|
||||
.dark .attachment-action-button:hover {
|
||||
@apply bg-zinc-800 text-white border-blue-500 !important;
|
||||
}
|
||||
|
||||
.audio-controls-light {
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
:my-lxmf-address-hash="config?.lxmf_address_hash"
|
||||
:selected-peer="selectedPeer"
|
||||
:conversations="conversations"
|
||||
@update:selected-peer="onPeerClick"
|
||||
@close="onCloseConversationViewer"
|
||||
@reload-conversations="getConversations"
|
||||
/>
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
</div>
|
||||
<template v-else-if="uri">
|
||||
<!-- QR code container -->
|
||||
<div class="p-4 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6 relative group">
|
||||
<div class="size-48 sm:size-64 flex items-center justify-center overflow-hidden">
|
||||
<div class="p-3 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6 relative group">
|
||||
<div class="size-40 sm:size-48 flex items-center justify-center overflow-hidden">
|
||||
<canvas ref="qrcode"></canvas>
|
||||
</div>
|
||||
<div
|
||||
@@ -47,7 +47,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full space-y-3">
|
||||
<div v-if="recipientHash" class="w-full space-y-3">
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-zinc-800/50 rounded-2xl p-3 border border-gray-100 dark:border-zinc-700/50"
|
||||
>
|
||||
<label
|
||||
class="block text-[9px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
|
||||
>
|
||||
LXMF URI
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<div
|
||||
class="flex-1 font-mono text-[10px] break-all text-gray-600 dark:text-zinc-300 bg-white dark:bg-zinc-900 p-2 rounded-lg border border-gray-200 dark:border-zinc-700 max-h-20 overflow-y-auto"
|
||||
>
|
||||
{{ uri }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="size-9 flex items-center justify-center bg-white dark:bg-zinc-900 text-gray-500 dark:text-zinc-400 rounded-lg border border-gray-200 dark:border-zinc-700 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 transition-all shadow-sm"
|
||||
title="Copy URI"
|
||||
@click="copyUri"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="content-copy" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98] text-sm"
|
||||
@click="printQRCode"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="printer" class="size-4" />
|
||||
Print
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-[0.98] text-sm"
|
||||
:disabled="isSending"
|
||||
@click="sendPaperMessage"
|
||||
>
|
||||
<template v-if="isSending">
|
||||
<div
|
||||
class="size-4 border-2 border-white/20 border-t-white rounded-full animate-spin"
|
||||
></div>
|
||||
Sending...
|
||||
</template>
|
||||
<template v-else>
|
||||
<MaterialDesignIcon icon-name="send" class="size-4" />
|
||||
Send
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full space-y-3">
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-zinc-800/50 rounded-2xl p-3 border border-gray-100 dark:border-zinc-700/50"
|
||||
>
|
||||
@@ -134,12 +188,18 @@ export default {
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
recipientHash: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ["close"],
|
||||
data() {
|
||||
return {
|
||||
uri: this.initialUri,
|
||||
isLoading: !this.initialUri,
|
||||
isSending: false,
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
@@ -173,7 +233,7 @@ export default {
|
||||
|
||||
try {
|
||||
await QRCode.toCanvas(this.$refs.qrcode, this.uri, {
|
||||
width: 320,
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: "#000000",
|
||||
@@ -211,11 +271,56 @@ export default {
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
if (dataUrl) {
|
||||
const link = document.createElement("a");
|
||||
link.download = `lxmf-paper-message-${this.messageHash.substring(0, 8)}.png`;
|
||||
link.download = `lxmf-paper-message-${this.messageHash ? this.messageHash.substring(0, 8) : Date.now()}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
},
|
||||
async sendPaperMessage() {
|
||||
const canvas = this.$refs.qrcode;
|
||||
if (!canvas || !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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 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") {
|
||||
ToastUtils.success("Paper message sent successfully");
|
||||
this.close();
|
||||
} else {
|
||||
ToastUtils.error(response.data.message || "Failed to send paper message");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to send paper message:", err);
|
||||
ToastUtils.error("Failed to send paper message");
|
||||
} finally {
|
||||
this.isSending = false;
|
||||
}
|
||||
},
|
||||
printQRCode() {
|
||||
const canvas = this.$refs.qrcode;
|
||||
if (!canvas) return;
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<div v-if="isBlocked(favourite.destination_hash)" class="border-t">
|
||||
<DropDownMenuItem @click.stop="onUnblockNode(favourite.destination_hash)">
|
||||
<MaterialDesignIcon icon-name="check-circle" class="w-5 h-5 text-green-500" />
|
||||
<span class="text-green-500">Unblock Node</span>
|
||||
<span class="text-green-500">Lift Banishment</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
</template>
|
||||
@@ -169,7 +169,7 @@
|
||||
</DropDownMenuItem>
|
||||
<DropDownMenuItem v-else @click.stop="onUnblockNode(node.identity_hash)">
|
||||
<MaterialDesignIcon icon-name="check-circle" class="w-5 h-5 text-green-500" />
|
||||
<span class="text-green-500">Unblock Node</span>
|
||||
<span class="text-green-500">Lift Banishment</span>
|
||||
</DropDownMenuItem>
|
||||
</template>
|
||||
</DropDownMenu>
|
||||
@@ -321,9 +321,9 @@ export default {
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/blocked-destinations/${identityHash}`);
|
||||
GlobalEmitter.emit("block-status-changed");
|
||||
DialogUtils.alert("Node unblocked successfully");
|
||||
DialogUtils.alert("Banishment lifted successfully");
|
||||
} catch (e) {
|
||||
DialogUtils.alert("Failed to unblock node");
|
||||
DialogUtils.alert("Failed to lift banishment");
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -40,28 +40,24 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button v-if="!isRunning" type="button" class="primary-chip px-4 py-2 text-sm" @click="start">
|
||||
<button v-if="!isRunning" type="button" class="primary-chip" @click="start">
|
||||
<MaterialDesignIcon icon-name="play" class="w-4 h-4" />
|
||||
{{ $t("ping.start_ping") }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50"
|
||||
class="secondary-chip !text-red-600 dark:!text-red-300 !border-red-200 dark:!border-red-500/50"
|
||||
@click="stop"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="pause" class="w-4 h-4" />
|
||||
{{ $t("ping.stop") }}
|
||||
</button>
|
||||
<button type="button" class="secondary-chip px-4 py-2 text-sm" @click="clear">
|
||||
<button type="button" class="secondary-chip" @click="clear">
|
||||
<MaterialDesignIcon icon-name="broom" class="w-4 h-4" />
|
||||
{{ $t("ping.clear_results") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-red-600/90 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-500 transition"
|
||||
@click="dropPath"
|
||||
>
|
||||
<button type="button" class="danger-chip" @click="dropPath">
|
||||
<MaterialDesignIcon icon-name="link-variant-remove" class="w-4 h-4" />
|
||||
{{ $t("ping.drop_path") }}
|
||||
</button>
|
||||
|
||||
@@ -15,6 +15,33 @@
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $t("rncp.description") }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-4 p-4 rounded-lg bg-blue-50/50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-900/20"
|
||||
>
|
||||
<div
|
||||
class="text-xs font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 mb-2"
|
||||
>
|
||||
{{ $t("rncp.usage_steps") }}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p
|
||||
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
|
||||
v-html="renderMarkdown($t('rncp.step_1'))"
|
||||
></p>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p
|
||||
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
|
||||
v-html="renderMarkdown($t('rncp.step_2'))"
|
||||
></p>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p
|
||||
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
|
||||
v-html="renderMarkdown($t('rncp.step_3'))"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 border-b border-gray-200 dark:border-zinc-700">
|
||||
@@ -493,6 +520,10 @@ export default {
|
||||
this.listenDestinationHash = null;
|
||||
this.listenResult = null;
|
||||
},
|
||||
renderMarkdown(text) {
|
||||
if (!text) return "";
|
||||
return text.replace(/\*\*(.*?)\*\*/g, "<b>$1</b>");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -48,11 +48,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="linkCount !== null"
|
||||
class="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
|
||||
>
|
||||
<div class="font-semibold">Active Links: {{ linkCount }}</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-if="linkCount !== null"
|
||||
class="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
|
||||
>
|
||||
<div class="font-semibold">Active Links: {{ linkCount }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="blackholeEnabled !== null"
|
||||
class="p-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300"
|
||||
>
|
||||
<div class="font-semibold flex justify-between items-center">
|
||||
<span>Blackhole: {{ blackholeEnabled ? "Publishing" : "Active" }}</span>
|
||||
<span class="text-sm opacity-80"> {{ blackholeCount }} Identities </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="blackholeSources.length > 0" class="glass-card space-y-3">
|
||||
<div class="font-semibold text-lg text-gray-900 dark:text-white">Blackhole Sources</div>
|
||||
<div class="grid gap-2">
|
||||
<div
|
||||
v-for="source in blackholeSources"
|
||||
:key="source"
|
||||
class="text-sm font-mono bg-gray-50 dark:bg-gray-800 p-2 rounded truncate"
|
||||
>
|
||||
{{ source }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,6 +214,9 @@ export default {
|
||||
linkCount: null,
|
||||
includeLinkStats: false,
|
||||
sorting: "",
|
||||
blackholeEnabled: null,
|
||||
blackholeSources: [],
|
||||
blackholeCount: 0,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@@ -215,6 +243,9 @@ export default {
|
||||
const response = await window.axios.get("/api/v1/rnstatus", { params });
|
||||
this.interfaces = response.data.interfaces || [];
|
||||
this.linkCount = response.data.link_count;
|
||||
this.blackholeEnabled = response.data.blackhole_enabled;
|
||||
this.blackholeSources = response.data.blackhole_sources || [];
|
||||
this.blackholeCount = response.data.blackhole_count || 0;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
|
||||
@@ -436,6 +436,40 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Network Security -->
|
||||
<section class="glass-card break-inside-avoid">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">RNS Security</div>
|
||||
<h2>Network Security</h2>
|
||||
<p>Manage mesh-level security features.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-4">
|
||||
<div class="setting-toggle">
|
||||
<div class="setting-toggle__label">
|
||||
<div class="setting-toggle__title">
|
||||
{{ $t("app.blackhole_integration_enabled") }}
|
||||
</div>
|
||||
<div class="setting-toggle__description text-xs text-gray-500">
|
||||
{{ $t("app.blackhole_integration_description") }}
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="config.blackhole_integration_enabled"
|
||||
@update:model-value="
|
||||
updateConfig(
|
||||
{
|
||||
blackhole_integration_enabled: config.blackhole_integration_enabled,
|
||||
},
|
||||
'blackhole_integration_enabled'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Transport -->
|
||||
<section class="glass-card break-inside-avoid">
|
||||
<header class="glass-card__header">
|
||||
@@ -494,14 +528,14 @@
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Privacy</div>
|
||||
<h2>Blocked</h2>
|
||||
<p>Manage blocked users and nodes</p>
|
||||
<h2>Banished</h2>
|
||||
<p>Manage Banished users and nodes</p>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'blocked' }" class="primary-chip"> Manage Blocked </RouterLink>
|
||||
<RouterLink :to="{ name: 'blocked' }" class="primary-chip"> Manage Banished </RouterLink>
|
||||
</header>
|
||||
<div class="glass-card__body">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Blocked users and nodes will not be able to send you messages, and their announces will
|
||||
Banished users and nodes will not be able to send you messages, and their announces will
|
||||
be ignored.
|
||||
</p>
|
||||
</div>
|
||||
@@ -867,6 +901,7 @@ export default {
|
||||
banished_effect_enabled: true,
|
||||
banished_text: "BANISHED",
|
||||
banished_color: "#dc2626",
|
||||
blackhole_integration_enabled: true,
|
||||
},
|
||||
saveTimeouts: {},
|
||||
shortcuts: [],
|
||||
@@ -1319,9 +1354,6 @@ export default {
|
||||
.setting-toggle__hint {
|
||||
@apply text-xs text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
.primary-chip {
|
||||
@apply inline-flex items-center gap-x-1 rounded-full bg-blue-600/90 px-4 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
|
||||
}
|
||||
.info-callout {
|
||||
@apply rounded-2xl border border-blue-100 dark:border-blue-900/40 bg-blue-50/60 dark:bg-blue-900/20 px-3 py-3 text-blue-900 dark:text-blue-100;
|
||||
}
|
||||
|
||||
@@ -138,8 +138,8 @@
|
||||
<h2 class="text-blue-600 dark:text-blue-400">Generated QR Code</h2>
|
||||
</div>
|
||||
<div class="glass-card__body flex flex-col items-center p-4 sm:p-6">
|
||||
<div class="p-4 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6">
|
||||
<div class="size-48 sm:size-64 flex items-center justify-center overflow-hidden">
|
||||
<div class="p-3 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6">
|
||||
<div class="size-40 sm:size-48 flex items-center justify-center overflow-hidden">
|
||||
<canvas ref="qrcode"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,11 +181,20 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-200 rounded-xl font-bold transition-all active:scale-[0.98] text-sm"
|
||||
@click="downloadQRCode"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-[0.98] text-sm"
|
||||
:disabled="isSending"
|
||||
@click="sendPaperMessage"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="download" class="size-4" />
|
||||
Save
|
||||
<template v-if="isSending">
|
||||
<div
|
||||
class="size-4 border-2 border-white/20 border-t-white rounded-full animate-spin"
|
||||
></div>
|
||||
Sending...
|
||||
</template>
|
||||
<template v-else>
|
||||
<MaterialDesignIcon icon-name="send" class="size-4" />
|
||||
Send
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,6 +236,7 @@ export default {
|
||||
isGenerating: false,
|
||||
generatedUri: null,
|
||||
ingestUri: "",
|
||||
isSending: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -284,7 +294,7 @@ export default {
|
||||
|
||||
try {
|
||||
await QRCode.toCanvas(this.$refs.qrcode, this.generatedUri, {
|
||||
width: 320,
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: "#000000",
|
||||
@@ -341,6 +351,54 @@ export default {
|
||||
link.click();
|
||||
}
|
||||
},
|
||||
async sendPaperMessage() {
|
||||
const canvas = this.$refs.qrcode;
|
||||
if (!canvas || !this.destinationHash || !this.generatedUri) 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.destinationHash,
|
||||
content: this.generatedUri,
|
||||
fields: {
|
||||
image: {
|
||||
image_type: "png",
|
||||
image_bytes: imageBytes,
|
||||
name: "qrcode.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") {
|
||||
ToastUtils.success("Paper message sent successfully");
|
||||
this.generatedUri = null;
|
||||
this.destinationHash = "";
|
||||
this.content = "";
|
||||
this.title = "";
|
||||
} else {
|
||||
ToastUtils.error(response.data.message || "Failed to send paper message");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to send paper message:", err);
|
||||
ToastUtils.error("Failed to send paper message");
|
||||
} finally {
|
||||
this.isSending = false;
|
||||
}
|
||||
},
|
||||
printQRCode() {
|
||||
const canvas = this.$refs.qrcode;
|
||||
if (!canvas) return;
|
||||
|
||||
311
meshchatx/src/frontend/components/tools/RNPathPage.vue
Normal file
311
meshchatx/src/frontend/components/tools/RNPathPage.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
|
||||
<!-- header -->
|
||||
<div
|
||||
class="flex items-center px-4 py-4 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-sm"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg">
|
||||
<MaterialDesignIcon icon-name="route" class="size-6 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">RNPath</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Reticulum Path Management Utility</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
class="p-2 text-gray-500 hover:text-indigo-500 dark:text-gray-400 dark:hover:text-indigo-400 transition-colors"
|
||||
title="Refresh"
|
||||
@click="refreshAll"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="refresh" class="size-6" :class="{ 'animate-spin': isLoading }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-6 space-y-6">
|
||||
<!-- tabs -->
|
||||
<div class="flex border-b border-gray-200 dark:border-zinc-800">
|
||||
<button
|
||||
v-for="t in ['table', 'rates', 'actions']"
|
||||
:key="t"
|
||||
class="px-6 py-3 text-sm font-semibold transition-colors border-b-2 -mb-px"
|
||||
:class="[
|
||||
tab === t
|
||||
? 'text-indigo-600 border-indigo-500 dark:text-indigo-400 dark:border-indigo-400'
|
||||
: 'text-gray-500 border-transparent hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
|
||||
]"
|
||||
@click="tab = t"
|
||||
>
|
||||
{{ t.charAt(0).toUpperCase() + t.slice(1) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- path table -->
|
||||
<div v-if="tab === 'table'" class="space-y-4">
|
||||
<div v-if="pathTable.length === 0" class="glass-card p-12 text-center text-gray-500">
|
||||
No paths currently known.
|
||||
</div>
|
||||
<div v-else class="grid gap-4">
|
||||
<div
|
||||
v-for="path in pathTable"
|
||||
:key="path.hash"
|
||||
class="glass-card p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-mono text-sm font-bold text-indigo-600 dark:text-indigo-400 truncate">
|
||||
{{ path.hash }}
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-0.5 text-[10px] font-bold bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 rounded uppercase tracking-wider"
|
||||
>
|
||||
{{ path.hops }} {{ path.hops === 1 ? "hop" : "hops" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">
|
||||
via {{ path.via }} on {{ path.interface }}
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-400 mt-1">Expires: {{ formatDate(path.expires) }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20 rounded-lg transition-colors border border-red-200 dark:border-red-900/30"
|
||||
@click="dropPath(path.hash)"
|
||||
>
|
||||
Drop Path
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- announce rates -->
|
||||
<div v-if="tab === 'rates'" class="space-y-4">
|
||||
<div v-if="rateTable.length === 0" class="glass-card p-12 text-center text-gray-500">
|
||||
No announce rate data available.
|
||||
</div>
|
||||
<div v-else class="grid gap-4">
|
||||
<div v-for="rate in rateTable" :key="rate.hash" class="glass-card p-4">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
|
||||
<span class="font-mono text-sm font-bold text-indigo-600 dark:text-indigo-400 truncate">
|
||||
{{ rate.hash }}
|
||||
</span>
|
||||
<span
|
||||
v-if="rate.blocked_until > Date.now() / 1000"
|
||||
class="px-2 py-0.5 text-[10px] font-bold bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded"
|
||||
>
|
||||
RATE LIMITED
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase text-gray-500">Last Heard</div>
|
||||
<div class="text-xs font-medium">{{ formatTimeAgo(rate.last) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase text-gray-500">Announces</div>
|
||||
<div class="text-xs font-medium">{{ rate.timestamps.length }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase text-gray-500">Violations</div>
|
||||
<div
|
||||
class="text-xs font-medium"
|
||||
:class="rate.rate_violations > 0 ? 'text-red-500' : ''"
|
||||
>
|
||||
{{ rate.rate_violations }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase text-gray-500">Rate</div>
|
||||
<div class="text-xs font-medium">{{ calculateRate(rate) }} / hr</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- manual actions -->
|
||||
<div v-if="tab === 'actions'" class="max-w-2xl mx-auto space-y-6">
|
||||
<!-- request path -->
|
||||
<section class="glass-card p-6 space-y-4">
|
||||
<h2 class="text-lg font-bold">Request Path</h2>
|
||||
<p class="text-sm text-gray-500">Broadcast a path request for a destination hash.</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="requestHash"
|
||||
type="text"
|
||||
placeholder="Destination Hash (32 hex chars)"
|
||||
class="input-field flex-1 font-mono"
|
||||
/>
|
||||
<button
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-500 transition shadow-lg shadow-indigo-500/20 active:scale-95 disabled:opacity-50"
|
||||
:disabled="requestHash.length !== 32"
|
||||
@click="requestPath"
|
||||
>
|
||||
Request
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- drop all via -->
|
||||
<section class="glass-card p-6 space-y-4">
|
||||
<h2 class="text-lg font-bold">Drop All Via</h2>
|
||||
<p class="text-sm text-gray-500">
|
||||
Remove all known paths routed through a specific transport instance.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="dropViaHash"
|
||||
type="text"
|
||||
placeholder="Transport Instance Hash"
|
||||
class="input-field flex-1 font-mono"
|
||||
/>
|
||||
<button
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-xl font-semibold hover:bg-red-500 transition shadow-lg shadow-red-500/20 active:scale-95 disabled:opacity-50"
|
||||
:disabled="dropViaHash.length !== 32"
|
||||
@click="dropAllVia"
|
||||
>
|
||||
Drop All
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- drop queues -->
|
||||
<section class="glass-card p-6 space-y-4">
|
||||
<h2 class="text-lg font-bold">Drop Announce Queues</h2>
|
||||
<p class="text-sm text-gray-500">
|
||||
Clear all outbound announce packets currently queued on all interfaces.
|
||||
</p>
|
||||
<button
|
||||
class="w-full px-4 py-3 bg-zinc-800 text-white rounded-xl font-semibold hover:bg-zinc-700 transition active:scale-95"
|
||||
@click="dropAnnounceQueues"
|
||||
>
|
||||
Purge All Queues
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import Utils from "../../js/Utils";
|
||||
|
||||
export default {
|
||||
name: "RNPathPage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tab: "table",
|
||||
isLoading: false,
|
||||
pathTable: [],
|
||||
rateTable: [],
|
||||
requestHash: "",
|
||||
dropViaHash: "",
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.refreshAll();
|
||||
},
|
||||
methods: {
|
||||
async refreshAll() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const [pathRes, rateRes] = await Promise.all([
|
||||
window.axios.get("/api/v1/rnpath/table"),
|
||||
window.axios.get("/api/v1/rnpath/rates"),
|
||||
]);
|
||||
this.pathTable = pathRes.data.table;
|
||||
this.rateTable = rateRes.data.rates;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error("Failed to fetch path data");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
async dropPath(hash) {
|
||||
if (!(await DialogUtils.confirm(`Are you sure you want to drop the path to ${hash}?`))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await window.axios.post("/api/v1/rnpath/drop", { destination_hash: hash });
|
||||
if (res.data.success) {
|
||||
ToastUtils.success("Path dropped");
|
||||
this.refreshAll();
|
||||
} else {
|
||||
ToastUtils.error("Could not drop path");
|
||||
}
|
||||
} catch {
|
||||
ToastUtils.error("Error dropping path");
|
||||
}
|
||||
},
|
||||
async requestPath() {
|
||||
try {
|
||||
await window.axios.post("/api/v1/rnpath/request", { destination_hash: this.requestHash });
|
||||
ToastUtils.success(`Path requested for ${this.requestHash.substring(0, 8)}...`);
|
||||
this.requestHash = "";
|
||||
// Path requests take time, don't refresh immediately
|
||||
} catch {
|
||||
ToastUtils.error("Failed to request path");
|
||||
}
|
||||
},
|
||||
async dropAllVia() {
|
||||
if (!(await DialogUtils.confirm(`Drop ALL paths via ${this.dropViaHash}?`))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await window.axios.post("/api/v1/rnpath/drop-via", {
|
||||
transport_instance_hash: this.dropViaHash,
|
||||
});
|
||||
if (res.data.success) {
|
||||
ToastUtils.success("Paths dropped");
|
||||
this.dropViaHash = "";
|
||||
this.refreshAll();
|
||||
}
|
||||
} catch {
|
||||
ToastUtils.error("Failed to drop paths");
|
||||
}
|
||||
},
|
||||
async dropAnnounceQueues() {
|
||||
if (!(await DialogUtils.confirm("Purge all announce queues? This cannot be undone."))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.axios.post("/api/v1/rnpath/drop-queues");
|
||||
ToastUtils.success("Announce queues purged");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to purge queues");
|
||||
}
|
||||
},
|
||||
calculateRate(rate) {
|
||||
if (rate.timestamps.length === 0) return 0;
|
||||
const startTs = rate.timestamps[0];
|
||||
const span = Math.max(Date.now() / 1000 - startTs, 3600.0);
|
||||
const spanHours = span / 3600.0;
|
||||
return (rate.timestamps.length / spanHours).toFixed(2);
|
||||
},
|
||||
formatDate(ts) {
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
},
|
||||
formatTimeAgo(ts) {
|
||||
return Utils.formatSecondsAgo(Math.floor(Date.now() / 1000 - ts));
|
||||
},
|
||||
},
|
||||
};
|
||||
</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-2xl shadow-lg;
|
||||
}
|
||||
.input-field {
|
||||
@apply bg-gray-50/90 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-xl focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 dark:focus:ring-indigo-500 dark:focus:border-indigo-500 block w-full p-3 text-gray-900 dark:text-gray-100 transition;
|
||||
}
|
||||
</style>
|
||||
@@ -70,6 +70,21 @@
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="{ name: 'rnpath' }" class="tool-card glass-card">
|
||||
<div
|
||||
class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="route" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("tools.rnpath.title") }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("tools.rnpath.description") }}
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="{ name: 'translator' }" class="tool-card glass-card">
|
||||
<div
|
||||
class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
|
||||
|
||||
Reference in New Issue
Block a user