feat(ui): various improvemetns and fixes to interface components with improved layout, add bulk actions for favourites, and implement APK sharing functionality for Android

This commit is contained in:
Ivan
2026-05-02 04:37:50 -05:00
parent 96229b6412
commit 1459f80a63
10 changed files with 777 additions and 166 deletions
+28 -18
View File
@@ -28,34 +28,44 @@
<template v-else>
<div
class="sticky top-0 z-100 flex bg-white dark:bg-zinc-950 border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-xs transition-colors overflow-x-hidden"
class="sticky top-0 z-100 flex bg-white dark:bg-zinc-950 border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-xs transition-colors"
>
<div class="flex w-full px-2 sm:px-4 overflow-x-auto no-scrollbar">
<div
class="flex w-full min-h-16 items-center gap-0 overflow-x-auto no-scrollbar pl-2 pr-2 sm:ps-0 sm:pe-4"
>
<button
type="button"
class="sm:hidden my-auto mr-4 text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
class="sm:hidden shrink-0 mr-2 text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
@click="isSidebarOpen = !isSidebarOpen"
>
<MaterialDesignIcon :icon-name="isSidebarOpen ? 'close' : 'menu'" class="size-6" />
</button>
<div
class="my-auto mr-2 hidden w-10 shrink-0 cursor-pointer overflow-hidden rounded-xl sm:flex sm:w-14"
@click="onAppNameClick"
>
<img class="h-10 w-10 object-contain p-1 sm:h-14 sm:w-14" :src="logoUrl" alt="" />
</div>
<div class="my-auto hidden sm:block">
<div
class="font-semibold cursor-pointer text-gray-900 dark:text-zinc-100 hover:text-blue-600 dark:hover:text-blue-400 transition-colors tracking-tight text-lg"
@click="onAppNameClick"
>
{{ $t("app.name") }}
<div class="flex min-w-0 flex-1 items-center gap-2 sm:flex-initial sm:gap-3">
<div class="hidden shrink-0 justify-start sm:flex sm:w-16 sm:justify-center">
<div
class="flex h-10 w-10 cursor-pointer items-center justify-center overflow-hidden rounded-xl sm:h-14 sm:w-14"
@click="onAppNameClick"
>
<img
class="h-9 w-9 max-h-full max-w-full object-contain sm:h-[3.25rem] sm:w-[3.25rem]"
:src="logoUrl"
alt=""
/>
</div>
</div>
<div class="text-sm text-gray-600 dark:text-zinc-300">
{{ $t("app.tagline") }}
<div class="hidden min-w-0 leading-tight sm:block">
<div
class="font-semibold cursor-pointer text-gray-900 dark:text-zinc-100 hover:text-blue-600 dark:hover:text-blue-400 transition-colors tracking-tight text-lg"
@click="onAppNameClick"
>
{{ $t("app.name") }}
</div>
<div class="text-sm text-gray-600 dark:text-zinc-300">
{{ $t("app.tagline") }}
</div>
</div>
</div>
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
<div class="flex ml-auto shrink-0 items-center mr-0 sm:mr-2 space-x-1 sm:space-x-2">
<button
type="button"
class="relative hidden sm:inline-flex 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"
@@ -409,6 +409,7 @@ import QRCode from "qrcode";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
import DialogUtils from "../../js/DialogUtils";
import LxmfUserIcon from "../LxmfUserIcon.vue";
@@ -605,14 +606,7 @@ export default {
const blob = new Blob([JSON.stringify({ contacts }, null, 2)], {
type: "application/json",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "contacts_export.json");
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
await DownloadUtils.downloadFile("contacts_export.json", blob);
ToastUtils.success(this.$t("contacts.export_success"));
} catch (e) {
ToastUtils.error(e?.response?.data?.message || this.$t("contacts.export_failed"));
@@ -130,30 +130,37 @@
</div>
</div>
<div class="flex-1 overflow-hidden glass-card max-w-6xl mx-auto w-full p-0 flex flex-col rounded-xs">
<div
class="flex-1 overflow-hidden glass-card max-w-6xl mx-auto w-full p-0 flex flex-col rounded-xs min-h-0"
>
<div
v-if="activeTab === 'logs'"
class="flex-1 overflow-auto p-3 sm:p-4 font-mono text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
class="debug-log-scroll flex-1 overflow-auto p-2 sm:p-4 font-mono text-[9px] sm:text-[10px] md:text-xs max-sm:leading-snug sm:leading-snug md:leading-relaxed select-text touch-pan-x bg-white dark:bg-zinc-950 min-h-0"
>
<div v-if="logs.length === 0" class="text-gray-500 italic text-center py-10">
<div v-if="logs.length === 0" class="text-gray-500 italic text-center py-10 text-sm sm:text-base">
{{ loading ? $t("debug.loading_logs") : $t("debug.no_logs") }}
</div>
<div
v-for="(log, index) in logs"
:key="index"
class="border-b border-gray-100 dark:border-zinc-900 py-1.5 flex gap-2 sm:gap-3 hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
class="debug-log-row border-b border-gray-100 dark:border-zinc-900 py-1 sm:py-1.5 flex gap-1.5 sm:gap-3 max-sm:flex-nowrap max-sm:min-w-max hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
:class="{ 'bg-red-50/30 dark:bg-red-900/10': log.is_anomaly }"
title="Tap to copy this log entry"
@click="copyLogLine(log)"
>
<span class="text-gray-400 shrink-0">{{ formatTime(log.timestamp) }}</span>
<span :class="levelClass(log.level)" class="w-12 shrink-0 font-bold uppercase">{{
log.level
}}</span>
<span class="text-blue-500 shrink-0 w-20 sm:w-24 overflow-hidden text-ellipsis italic"
<span class="text-gray-400 shrink-0 max-sm:text-[8px]">{{ formatTime(log.timestamp) }}</span>
<span
:class="levelClass(log.level)"
class="w-11 sm:w-12 shrink-0 font-bold uppercase max-sm:text-[8px] max-sm:tracking-tight"
>{{ log.level }}</span
>
<span
class="text-blue-500 shrink-0 w-[4.5rem] sm:w-24 overflow-hidden text-ellipsis italic max-sm:text-[8px]"
>[{{ log.module }}]</span
>
<span class="text-gray-800 dark:text-gray-200 wrap-break-word flex-1">
<span
class="text-gray-800 dark:text-gray-200 flex-1 max-sm:whitespace-nowrap sm:wrap-break-word"
>
{{ log.message }}
<span
v-if="log.is_anomaly"
@@ -168,30 +175,39 @@
<div
v-else
class="flex-1 overflow-auto p-3 sm:p-4 font-mono text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
class="debug-log-scroll flex-1 overflow-auto p-2 sm:p-4 font-mono text-[9px] sm:text-[10px] md:text-xs max-sm:leading-snug sm:leading-snug md:leading-relaxed select-text touch-pan-x bg-white dark:bg-zinc-950 min-h-0"
>
<div v-if="accessAttempts.length === 0" class="text-gray-500 italic text-center py-10">
<div
v-if="accessAttempts.length === 0"
class="text-gray-500 italic text-center py-10 text-sm sm:text-base"
>
{{ accessLoading ? $t("debug.loading_access") : $t("debug.no_access") }}
</div>
<div
v-for="row in accessAttempts"
:key="row.id"
class="border-b border-gray-100 dark:border-zinc-900 py-2 flex flex-col gap-1 hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
class="border-b border-gray-100 dark:border-zinc-900 py-1.5 sm:py-2 flex flex-col gap-0.5 sm:gap-1 hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
title="Tap to copy this access entry"
@click="copyAccessLine(row)"
>
<div class="flex flex-wrap gap-x-3 gap-y-1 items-center">
<div
class="flex flex-wrap gap-x-2 gap-y-0.5 sm:gap-x-3 sm:gap-y-1 items-center max-sm:text-[9px]"
>
<span class="text-gray-400 shrink-0">{{ formatTime(row.created_at) }}</span>
<span class="text-amber-600 dark:text-amber-400 font-semibold">{{ row.outcome }}</span>
<span class="text-cyan-600 dark:text-cyan-400">{{ row.method }} {{ row.path }}</span>
<span class="text-cyan-600 dark:text-cyan-400 max-sm:break-all sm:min-w-0"
>{{ row.method }} {{ row.path }}</span
>
</div>
<div class="text-gray-600 dark:text-gray-400 break-all pl-0">
<div class="text-gray-600 dark:text-gray-400 break-all pl-0 max-sm:text-[8px]">
<span class="text-gray-500">IP</span> {{ row.client_ip }}
</div>
<div class="text-gray-600 dark:text-gray-400 break-all">
<div class="text-gray-600 dark:text-gray-400 break-all max-sm:text-[8px]">
<span class="text-gray-500">UA</span> {{ row.user_agent || "—" }}
</div>
<div v-if="row.detail" class="text-gray-500 text-[10px]">{{ row.detail }}</div>
<div v-if="row.detail" class="text-gray-500 max-sm:text-[8px] sm:text-[10px]">
{{ row.detail }}
</div>
</div>
</div>
@@ -481,4 +497,8 @@ export default {
.glass-card {
border-radius: 2px !important;
}
.debug-log-scroll {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
</style>
@@ -47,6 +47,12 @@
<span :class="statusChipClass" class="shrink-0">{{
isInterfaceEnabled(iface) ? $t("app.enabled") : $t("app.disabled")
}}</span>
<span
v-if="isReticulumRunning && isInterfaceEnabled(iface)"
:class="ifaceLinkStatusChipClass"
class="shrink-0"
>{{ ifaceLinkStatusLabel }}</span
>
<span v-if="isDiscoverable()" class="discoverable-chip shrink-0">Discoverable</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300 wrap-break-word min-w-0">
@@ -56,10 +62,10 @@
<span v-if="iface._stats?.bitrate" class="stat-chip"
>{{ $t("interface.bitrate") }} {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</span
>
<span class="stat-chip"
<span class="stat-chip" :class="{ 'stat-chip--zero-traffic': isIfaceStatBytesZero('txb') }"
>{{ $t("interface.tx") }} {{ formatBytes(iface._stats?.txb ?? 0) }}</span
>
<span class="stat-chip"
<span class="stat-chip" :class="{ 'stat-chip--zero-traffic': isIfaceStatBytesZero('rxb') }"
>{{ $t("interface.rx") }} {{ formatBytes(iface._stats?.rxb ?? 0) }}</span
>
<span v-if="iface.type === 'RNodeInterface' && iface._stats?.noise_floor" class="stat-chip"
@@ -259,6 +265,50 @@ export default {
? "inline-flex items-center rounded-full bg-green-100 text-green-700 px-2 py-0.5 text-xs font-semibold"
: "inline-flex items-center rounded-full bg-red-100 text-red-700 px-2 py-0.5 text-xs font-semibold";
},
ifaceLinkStatusKey() {
const v = this.normalizedIfaceLinkUp;
if (v === true) return "up";
if (v === false) return "down";
return null;
},
ifaceLinkStatusLabel() {
const key = this.ifaceLinkStatusKey;
if (key === "up") return this.$t("interface.link_up");
if (key === "down") return this.$t("interface.link_down");
return this.$t("interface.link_unknown");
},
ifaceLinkStatusChipClass() {
const key = this.ifaceLinkStatusKey;
if (key === "up") {
return "inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/45 dark:text-emerald-100 px-2 py-0.5 text-xs font-semibold";
}
if (key === "down") {
return "inline-flex items-center rounded-full bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-100 px-2 py-0.5 text-xs font-semibold";
}
return "inline-flex items-center rounded-full bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-300 px-2 py-0.5 text-xs font-semibold";
},
normalizedIfaceLinkUp() {
if (!this.isReticulumRunning || !this.isInterfaceEnabled(this.iface)) {
return null;
}
const st = this.iface._stats;
if (!st || typeof st !== "object") {
return null;
}
if ("status" in st) {
const s = st.status;
if (s === true) return true;
if (s === false) return false;
if (typeof s === "string") {
const t = s.toLowerCase();
if (t === "up") return true;
if (t === "down") return false;
}
}
if (st.connected === true || st.online === true) return true;
if (st.connected === false || st.online === false) return false;
return null;
},
},
methods: {
onIFACSignatureClick: function (ifacSignature) {
@@ -298,6 +348,15 @@ export default {
formatFrequency(hz) {
return Utils.formatFrequency(hz);
},
isIfaceStatBytesZero(field) {
const st = this.iface._stats;
if (!st) {
return false;
}
const raw = st[field];
const n = raw == null ? 0 : Number(raw);
return (Number.isFinite(n) ? n : 0) === 0;
},
},
};
</script>
@@ -317,6 +376,9 @@ export default {
.stat-chip {
@apply inline-flex items-center rounded-full border border-gray-200 dark:border-zinc-700 px-2 py-0.5;
}
.stat-chip--zero-traffic {
@apply border-red-400 bg-red-50 text-red-800 dark:border-red-700 dark:bg-red-950/50 dark:text-red-200 font-semibold;
}
.ifac-line {
@apply text-xs flex flex-wrap items-center gap-1;
}
@@ -4,11 +4,11 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-linear-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="flex-1 overflow-y-auto overflow-x-hidden w-full px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6">
<div class="flex-1 overflow-y-auto overflow-x-hidden w-full px-3 sm:px-5 md:px-5 lg:px-8 py-3 sm:py-4">
<div class="space-y-0 w-full min-w-0 max-w-6xl xl:max-w-7xl 2xl:max-w-360 mx-auto flex-1">
<div
v-if="showRestartReminder"
class="bg-amber-600 text-white border border-amber-500/30 p-4 sm:rounded-xl flex flex-wrap gap-3 items-center mb-4 sm:mb-6"
class="bg-amber-600 text-white border border-amber-500/30 p-4 sm:rounded-xl flex flex-wrap gap-3 items-center mb-3 sm:mb-4"
>
<div class="flex items-center gap-3">
<MaterialDesignIcon icon-name="alert" class="w-6 h-6" />
@@ -29,7 +29,7 @@
</div>
<div
class="interfaces-section interfaces-section--hero flex flex-col lg:flex-row lg:items-center justify-between gap-6"
class="interfaces-section interfaces-section--hero flex flex-col lg:flex-row lg:items-center justify-between gap-4"
>
<div class="space-y-3 flex-1 min-w-0">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -255,7 +255,7 @@
<button
type="button"
class="secondary-chip text-xs"
@click="loadDiscoveredInterfaces"
@click="refreshDiscoveredInterfacesList"
>
<MaterialDesignIcon icon-name="refresh" class="w-4 h-4" />
Refresh
@@ -911,11 +911,39 @@ export default {
return fromActive || fromStats;
},
},
watch: {
statusFilter(value) {
try {
localStorage.setItem("meshchatx.interfaces.statusFilter", value);
} catch {
/* ignore */
}
},
discoveredStatusFilter(value) {
try {
localStorage.setItem("meshchatx.interfaces.discoveredStatusFilter", value);
} catch {
/* ignore */
}
},
},
beforeUnmount() {
clearInterval(this.reloadInterval);
clearInterval(this.discoveryInterval);
},
mounted() {
try {
const sf = localStorage.getItem("meshchatx.interfaces.statusFilter");
if (sf === "all" || sf === "enabled" || sf === "disabled") {
this.statusFilter = sf;
}
const df = localStorage.getItem("meshchatx.interfaces.discoveredStatusFilter");
if (df === "all" || df === "connected") {
this.discoveredStatusFilter = df;
}
} catch {
/* ignore */
}
this.loadInterfaces();
this.updateInterfaceStats();
this.loadDiscoveryConfig();
@@ -1088,8 +1116,10 @@ export default {
this.discoveredInterfaces = Array.from(merged.values());
this.discoveredActive = active;
return true;
} catch (e) {
console.log(e);
return false;
}
},
discoveryKey(iface) {
@@ -1475,6 +1505,14 @@ export default {
setStatusFilter(value) {
this.statusFilter = value;
},
async refreshDiscoveredInterfacesList() {
const ok = await this.loadDiscoveredInterfaces();
if (ok) {
ToastUtils.success(this.$t("interfaces.discovery_list_refreshed"));
} else {
ToastUtils.error(this.$t("interfaces.discovery_list_refresh_failed"));
}
},
filterChipClass(isActive) {
return isActive ? "primary-chip text-xs" : "secondary-chip text-xs";
},
@@ -1519,12 +1557,12 @@ export default {
<style scoped>
@reference "../../style.css";
.interfaces-section {
@apply w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-6 sm:py-8;
@apply w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6;
}
.interfaces-section--hero {
@apply border-b border-gray-200/60 dark:border-zinc-800/60 py-6 sm:py-8;
@apply border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6;
}
.interfaces-subpanel {
@apply mt-6 pt-6 border-t border-gray-200/50 dark:border-zinc-800/50 first:mt-0 first:pt-0 first:border-0;
@apply mt-4 pt-4 border-t border-gray-200/50 dark:border-zinc-800/50 first:mt-0 first:pt-0 first:border-0;
}
</style>
@@ -2643,11 +2643,11 @@ export default {
const cfg = this.config;
const a = cfg?.translator_argos_enabled;
const l = cfg?.translator_libretranslate_enabled;
const onlyLibre = Boolean(l) && !a;
if (onlyLibre) {
return "auto";
if (!a && !l) {
return this.normalizedLocaleCode(cfg?.language || this.$i18n.locale) || "en";
}
return this.normalizedLocaleCode(cfg?.language || this.$i18n.locale) || "en";
return "auto";
},
_readSavedTranslateTargetLang() {
let v = null;
@@ -2720,10 +2720,10 @@ export default {
const cfg = this.config;
const a = cfg?.translator_argos_enabled;
const l = cfg?.translator_libretranslate_enabled;
if (l && !a) {
return "auto";
if (!a && !l) {
return this.normalizedLocaleCode(cfg?.language || this.$i18n.locale) || "en";
}
return this.normalizedLocaleCode(cfg?.language || this.$i18n.locale) || "en";
return "auto";
},
onTranslateTargetBarClickOutside() {
if (this.isTranslateTargetModalWorking) {
@@ -2831,15 +2831,8 @@ export default {
return;
}
const useArgos = Boolean(a) && !l;
const onlyLibre = Boolean(l) && !a;
const target = String(targetLang).toLowerCase().slice(0, 8);
const source_lang = onlyLibre
? "auto"
: this.normalizedLocaleCode(cfg?.language || this.$i18n.locale) || "en";
if (useArgos && source_lang && target && source_lang === target) {
ToastUtils.error(this.$t("messages.translate_target_invalid"));
throw new Error("target");
}
const source_lang = "auto";
this.isTranslatingMessage = true;
try {
const response = await window.api.post("/api/v1/translator/translate", {
@@ -2855,10 +2848,8 @@ export default {
});
}
} catch (e) {
if (e?.message !== "target") {
console.error("Translation failed:", e);
ToastUtils.error(this.$t("messages.translation_failed"));
}
console.error("Translation failed:", e);
ToastUtils.error(this.$t("messages.translation_failed"));
throw e;
} finally {
this.isTranslatingMessage = false;
@@ -2877,15 +2868,8 @@ export default {
return;
}
const useArgos = Boolean(a) && !l;
const onlyLibre = Boolean(l) && !a;
const target = String(targetLang).toLowerCase().slice(0, 8);
const source_lang = onlyLibre
? "auto"
: this.normalizedLocaleCode(cfg?.language || this.$i18n.locale) || "en";
if (useArgos && source_lang && target && source_lang === target) {
ToastUtils.error(this.$t("messages.translate_target_invalid"));
return;
}
const source_lang = "auto";
this.messageBubbleTranslation[hash] = {
loading: true,
showOriginal: false,
@@ -2,6 +2,15 @@
<template>
<div class="flex flex-1 min-w-0 h-full overflow-hidden" :class="{ 'flex-row-reverse': messagesSidebarOnRight }">
<input
ref="foldersImportInput"
type="file"
accept=".json,application/json"
class="sr-only absolute m-[-1px] h-px w-px overflow-hidden border-0 p-0 whitespace-nowrap"
tabindex="-1"
aria-hidden="true"
@change="onFoldersImportFileSelected"
/>
<MessagesSidebar
v-if="!isPopoutMode"
:class="{ 'hidden sm:flex': destinationHash }"
@@ -257,6 +266,7 @@ function snapshotGlobalConfig() {
return GlobalState.config && typeof GlobalState.config === "object" ? { ...GlobalState.config } : {};
}
import DialogUtils from "../../js/DialogUtils";
import DownloadUtils from "../../js/DownloadUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
import { lxmfConversationListPreview } from "../../js/lxmfReactions";
@@ -857,38 +867,39 @@ export default {
const response = await window.api.get("/api/v1/lxmf/folders/export");
const data = JSON.stringify(response.data, null, 2);
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `meshchatx-folders-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
await DownloadUtils.downloadFile(
`meshchatx-folders-${new Date().toISOString().slice(0, 10)}.json`,
blob
);
} catch {
ToastUtils.error(this.$t("messages.failed_export_folders"));
}
},
async onImportFolders() {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (re) => {
try {
const data = JSON.parse(re.target.result);
await window.api.post("/api/v1/lxmf/folders/import", data);
await this.getFolders();
await this.getConversations();
ToastUtils.success(this.$t("messages.folders_imported"));
} catch {
ToastUtils.error(this.$t("messages.failed_import_folders"));
}
};
reader.readAsText(file);
onImportFolders() {
const input = this.$refs.foldersImportInput;
if (input && typeof input.click === "function") {
input.click();
}
},
onFoldersImportFileSelected(event) {
const target = event.target;
const file = target.files?.[0];
target.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = async (re) => {
try {
const data = JSON.parse(re.target.result);
await window.api.post("/api/v1/lxmf/folders/import", data);
await this.getFolders();
await this.getConversations();
ToastUtils.success(this.$t("messages.folders_imported"));
} catch {
ToastUtils.error(this.$t("messages.failed_import_folders"));
}
};
input.click();
reader.readAsText(file);
},
onFolderClick(folderId) {
this.selectedFolderId = folderId;
@@ -18,6 +18,8 @@
@rename-favourite="onRenameFavourite"
@remove-favourite="onRemoveFavourite"
@add-favourite="addFavourite"
@bulk-remove-favourites="onBulkRemoveFavourites"
@bulk-add-favourites="onBulkAddFavouritesFromAnnounces"
@nodes-search-changed="onNodesSearchChanged"
@load-more-nodes="loadMoreNodes"
@toggle-collapse="nomadNetworkSidebarCollapsed = !nomadNetworkSidebarCollapsed"
@@ -76,8 +78,8 @@
<div class="my-auto dark:text-gray-100 flex-1 min-w-0 flex items-baseline gap-1 overflow-hidden">
<span
class="font-semibold truncate inline-block min-w-0 max-w-[min(100%,12rem)] sm:max-w-xs md:max-w-sm"
:title="selectedNode.display_name"
>{{ selectedNode.display_name }}</span
:title="selectedNode.custom_display_name || selectedNode.display_name"
>{{ selectedNode.custom_display_name || selectedNode.display_name }}</span
>
<span
v-if="selectedNodePath"
@@ -1102,6 +1104,49 @@ export default {
// update favourites
this.getFavourites();
},
async onBulkRemoveFavourites(hashes) {
if (!Array.isArray(hashes) || hashes.length === 0) {
return;
}
let removed = 0;
for (const h of hashes) {
try {
await window.api.delete(`/api/v1/favourites/${h}`);
removed += 1;
} catch (e) {
console.log(e);
}
}
await this.getFavourites();
if (removed > 0) {
ToastUtils.success(this.$t("nomadnet.bulk_remove_favourites_done", { count: removed }));
}
},
async onBulkAddFavouritesFromAnnounces(nodes) {
if (!Array.isArray(nodes) || nodes.length === 0) {
return;
}
let added = 0;
for (const node of nodes) {
if (this.isFavourite(node.destination_hash)) {
continue;
}
try {
await window.api.post("/api/v1/favourites/add", {
destination_hash: node.destination_hash,
display_name: node.display_name,
aspect: "nomadnetwork.node",
});
added += 1;
} catch (e) {
console.log(e);
}
}
await this.getFavourites();
if (added > 0) {
ToastUtils.success(this.$t("nomadnet.bulk_add_favourites_done", { count: added }));
}
},
async getNomadnetworkNodeAnnounces(append = false) {
try {
if (!append) {
@@ -1779,15 +1824,35 @@ export default {
if (displayName == null) {
return;
}
const trimmed = typeof displayName === "string" ? displayName.trim() : "";
if (!trimmed) {
return;
}
try {
// rename on server
await window.api.post(`/api/v1/favourites/${favourite.destination_hash}/rename`, {
display_name: displayName,
display_name: trimmed,
});
// reload favourites
await this.getFavourites();
const dh = favourite.destination_hash;
if (this.nodes[dh]) {
this.nodes[dh] = {
...this.nodes[dh],
custom_display_name: trimmed,
display_name: trimmed,
};
}
if (this.selectedNode?.destination_hash === dh) {
this.selectedNode = {
...this.selectedNode,
custom_display_name: trimmed,
display_name: trimmed,
};
}
} catch (e) {
console.log(e);
ToastUtils.error(this.$t("nomadnet.failed_rename_favourite"));
@@ -77,7 +77,7 @@
? 'ring-2 ring-blue-500 ring-offset-1 ring-offset-white dark:ring-offset-zinc-950'
: 'hover:bg-white/10'
"
:title="node.display_name"
:title="node.custom_display_name || node.display_name"
@click="onNodeClick(node)"
>
<MaterialDesignIcon icon-name="satellite-uplink" class="size-6 text-gray-600 dark:text-gray-300" />
@@ -116,18 +116,86 @@
</div>
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col min-h-0">
<div class="p-3 border-b border-gray-200 dark:border-zinc-800">
<div class="p-3 border-b border-gray-200 dark:border-zinc-800 space-y-2">
<input
v-model="favouritesSearchTerm"
type="text"
:placeholder="$t('nomadnet.search_favourites_placeholder', { count: favourites.length })"
class="input-field w-full rounded-none"
/>
<div
v-if="favouritesSelectionMode"
class="flex flex-col gap-2 px-2 py-2 bg-blue-50 dark:bg-blue-900/10 rounded-lg"
>
<div class="flex items-center gap-2 min-w-0 w-full">
<div class="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
<input
type="checkbox"
:checked="allVisibleFavouritesSelected"
class="rounded-sm border-gray-300 text-blue-600 focus:ring-blue-500 shrink-0"
@change="toggleSelectAllVisibleFavourites"
/>
<span
class="text-xs font-semibold text-blue-700 dark:text-blue-400 truncate leading-none"
>
{{ $t("nomadnet.bulk_selected_count", { count: selectedFavouriteHashes.length }) }}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<div class="relative inline-flex items-center">
<button
type="button"
class="inline-flex items-center whitespace-nowrap rounded px-0 py-0.5 text-xs font-bold leading-none text-blue-600 dark:text-blue-400 hover:underline disabled:pointer-events-none disabled:opacity-40"
:disabled="selectedFavouriteHashes.length === 0"
@click="favouriteBulkMoveMenuOpen = !favouriteBulkMoveMenuOpen"
>
{{ $t("nomadnet.bulk_move_to_section") }}
</button>
<div
v-if="favouriteBulkMoveMenuOpen"
v-click-outside="{ handler: closeFavouriteBulkMoveMenu, capture: true }"
class="absolute right-0 top-full mt-1 z-60 min-w-[10rem] max-h-56 overflow-y-auto bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 py-1"
>
<button
v-for="section in orderedSections"
:key="'bulk-move-' + section.id"
type="button"
class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700"
@click="bulkMoveSelectedFavouritesToSection(section.id)"
>
{{ section.name }}
</button>
</div>
</div>
<button
type="button"
class="inline-flex items-center whitespace-nowrap rounded px-0 py-0.5 text-xs font-bold leading-none text-red-600 dark:text-red-400 hover:underline disabled:pointer-events-none disabled:opacity-40"
:disabled="selectedFavouriteHashes.length === 0"
@click="bulkRemoveSelectedFavourites"
>
{{ $t("nomadnet.bulk_remove") }}
</button>
</div>
</div>
</div>
</div>
<div
class="flex items-center justify-between px-3 pt-2 text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
<span class="font-semibold">Sections</span>
<div class="flex items-center gap-1 min-w-0">
<button
type="button"
class="shrink-0 inline-flex items-center justify-center p-0.5 rounded-sm text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors leading-none"
:title="$t('nomadnet.sidebar_selection_mode')"
:class="{ 'text-blue-500 dark:text-blue-400': favouritesSelectionMode }"
@click.stop="toggleFavouritesSelectionMode"
>
<span class="block size-[14px]">
<MaterialDesignIcon icon-name="checkbox-multiple-marked-outline" />
</span>
</button>
<span class="font-semibold truncate">Sections</span>
</div>
<button
type="button"
class="inline-flex items-center gap-1 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
@@ -138,14 +206,24 @@
</button>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div v-if="favourites.length === 0" class="empty-state">
<div v-if="favouritesSearchNoResults" class="empty-state empty-state--panel">
<MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
<div class="font-semibold">{{ $t("nomadnet.no_favourites") }}</div>
<div class="font-semibold">{{ $t("nomadnet.favourites_search_no_results") }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ $t("nomadnet.add_nodes_from_announces") }}
{{ $t("nomadnet.favourites_search_try_other") }}
</div>
</div>
<div v-else-if="hasFavouriteResults" class="space-y-3 pt-2">
<div v-else class="space-y-3 pt-2">
<div
v-if="favourites.length === 0"
class="empty-state empty-state--compact border border-dashed border-gray-200 dark:border-zinc-800 rounded-xl py-4 mb-1"
>
<MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
<div class="font-semibold">{{ $t("nomadnet.no_favourites") }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ $t("nomadnet.add_nodes_from_announces") }}
</div>
</div>
<div
v-for="section in sectionsWithFavourites"
:key="section.id"
@@ -221,18 +299,34 @@
favourite.destination_hash === selectedDestinationHash
? 'favourite-card--active'
: '',
draggingFavouriteHash === favourite.destination_hash
isFavouriteRowDragging(favourite.destination_hash)
? 'favourite-card--dragging'
: '',
favouritesSelectionMode &&
selectedFavouriteHashes.includes(favourite.destination_hash)
? 'ring-1 ring-blue-400/60 dark:ring-blue-500/50'
: '',
]"
draggable="true"
@click="onFavouriteClick(favourite)"
@click="onFavouriteRowActivate(favourite)"
@contextmenu.prevent="openFavouriteContextMenu($event, favourite, section.id)"
@dragstart="onFavouriteDragStart($event, favourite, section.id)"
@dragover.prevent="onFavouriteDragOver($event)"
@drop.prevent="onFavouriteDrop($event, section.id, favourite)"
@dragend="onFavouriteDragEnd"
>
<div
v-if="favouritesSelectionMode"
class="my-auto mr-1 px-0.5 shrink-0"
@click.stop
>
<input
type="checkbox"
:checked="selectedFavouriteHashes.includes(favourite.destination_hash)"
class="rounded-sm border-gray-300 text-blue-600 focus:ring-blue-500"
@change="toggleSelectFavourite(favourite.destination_hash)"
/>
</div>
<div
v-if="
GlobalState.config.banished_effect_enabled &&
@@ -277,16 +371,11 @@
v-if="section.favourites.length === 0"
class="text-xs text-gray-500 dark:text-gray-400 px-3 pb-2 italic"
>
No favourites in this section.
{{ $t("nomadnet.no_favourites_in_section") }}
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
<div class="font-semibold">No favourites match your search</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Try a different search term.</div>
</div>
</div>
<!-- Favourite Context Menu (Teleport to body to avoid overflow clipping) -->
@@ -380,14 +469,65 @@
</div>
<div v-else class="flex-1 flex flex-col min-h-0">
<div class="p-3 border-b border-gray-200 dark:border-zinc-800">
<input
:value="nodesSearchTerm"
type="text"
:placeholder="$t('nomadnet.search_placeholder_announces', { count: totalNodesCount })"
class="input-field w-full rounded-none"
@input="onNodesSearchInput"
/>
<div class="p-3 border-b border-gray-200 dark:border-zinc-800 space-y-2">
<div class="flex gap-1.5 items-center">
<input
:value="nodesSearchTerm"
type="text"
:placeholder="$t('nomadnet.search_placeholder_announces', { count: totalNodesCount })"
class="input-field flex-1 min-w-0 rounded-none"
@input="onNodesSearchInput"
/>
<button
type="button"
class="shrink-0 self-center inline-flex items-center justify-center p-0.5 rounded-sm text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors leading-none"
:title="$t('nomadnet.sidebar_selection_mode')"
:class="{ 'text-blue-500 dark:text-blue-400': announcesSelectionMode }"
@click="toggleAnnouncesSelectionMode"
>
<span class="block size-[14px]">
<MaterialDesignIcon icon-name="checkbox-multiple-marked-outline" />
</span>
</button>
</div>
<div
v-if="announcesSelectionMode"
class="flex flex-col gap-2 px-2 py-2 bg-blue-50 dark:bg-blue-900/10 rounded-lg"
>
<div class="flex items-center gap-2 min-w-0 w-full">
<div class="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
<input
type="checkbox"
:checked="allVisibleAnnouncesSelected"
class="rounded-sm border-gray-300 text-blue-600 focus:ring-blue-500 shrink-0"
@change="toggleSelectAllVisibleAnnounces"
/>
<span
class="text-xs font-semibold text-blue-700 dark:text-blue-400 truncate leading-none"
>
{{ $t("nomadnet.bulk_selected_count", { count: selectedAnnounceHashes.length }) }}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
class="inline-flex items-center whitespace-nowrap rounded px-0 py-0.5 text-xs font-bold leading-none text-yellow-600 dark:text-yellow-400 hover:underline disabled:pointer-events-none disabled:opacity-40"
:disabled="selectedAnnounceHashes.length === 0"
@click="bulkAddSelectedAnnouncesToFavourites"
>
{{ $t("nomadnet.bulk_add_to_favourites") }}
</button>
<button
type="button"
class="inline-flex items-center whitespace-nowrap rounded px-0 py-0.5 text-xs font-bold leading-none text-red-600 dark:text-red-400 hover:underline disabled:pointer-events-none disabled:opacity-40"
:disabled="selectedAnnounceHashes.length === 0"
@click="bulkBanishSelectedAnnounces"
>
{{ $t("nomadnet.bulk_block_nodes") }}
</button>
</div>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4" @scroll="onNodesScroll">
<div v-if="searchedNodes.length > 0" class="space-y-2 pt-2">
@@ -395,7 +535,12 @@
v-for="node of searchedNodes"
:key="node.destination_hash"
class="announce-card relative"
:class="{ 'announce-card--active': node.destination_hash === selectedDestinationHash }"
:class="[
node.destination_hash === selectedDestinationHash ? 'announce-card--active' : '',
announcesSelectionMode && selectedAnnounceHashes.includes(node.destination_hash)
? 'ring-1 ring-blue-400/60 dark:ring-blue-500/50'
: '',
]"
@contextmenu.prevent="openAnnounceContextMenu($event, node)"
>
<!-- banished overlay -->
@@ -413,17 +558,25 @@
<div
class="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
@click="onNodeClick(node)"
@click="onAnnounceRowActivate(node)"
>
<div v-if="announcesSelectionMode" class="my-auto shrink-0 px-0.5" @click.stop>
<input
type="checkbox"
:checked="selectedAnnounceHashes.includes(node.destination_hash)"
class="rounded-sm border-gray-300 text-blue-600 focus:ring-blue-500"
@change="toggleSelectAnnounce(node.destination_hash)"
/>
</div>
<div class="announce-card__icon shrink-0">
<MaterialDesignIcon icon-name="satellite-uplink" class="w-5 h-5" />
</div>
<div class="min-w-0 flex-1">
<div
class="text-sm font-semibold text-gray-900 dark:text-white truncate"
:title="node.display_name"
:title="node.custom_display_name || node.display_name"
>
{{ node.display_name }}
{{ node.custom_display_name || node.display_name }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 flex flex-col gap-0.5">
<span class="truncate">{{
@@ -473,7 +626,7 @@
<MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
</div>
</div>
<div v-else class="empty-state">
<div v-else class="empty-state empty-state--panel">
<MaterialDesignIcon icon-name="radar" class="w-8 h-8" />
<div class="font-semibold">{{ $t("nomadnet.no_announces_yet") }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
@@ -596,17 +749,25 @@ export default {
"nodes-search-changed",
"load-more-nodes",
"toggle-collapse",
"bulk-remove-favourites",
"bulk-add-favourites",
],
data() {
return {
GlobalState,
tab: "favourites",
favouritesSearchTerm: "",
favouritesSelectionMode: false,
announcesSelectionMode: false,
selectedFavouriteHashes: [],
selectedAnnounceHashes: [],
favouriteBulkMoveMenuOpen: false,
defaultSectionId: "default",
sections: [],
sectionOrder: [],
favouritesBySection: {},
draggingFavouriteHash: null,
draggingFavouriteHashes: [],
draggingFavouriteSectionId: null,
dragOverSectionId: null,
draggingSectionId: null,
@@ -665,7 +826,8 @@ export default {
searchedNodes() {
return this.nodesOrderedByLatestAnnounce.filter((node) => {
const search = this.nodesSearchTerm.toLowerCase();
const matchesDisplayName = node.display_name.toLowerCase().includes(search);
const label = (node.custom_display_name || node.display_name || "").toLowerCase();
const matchesDisplayName = label.includes(search);
const matchesDestinationHash = node.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesDestinationHash;
});
@@ -689,14 +851,14 @@ export default {
return { ...section, favourites };
});
},
hasFavouriteResults() {
favouritesSearchNoResults() {
if (this.favourites.length === 0) {
return false;
}
if (this.favouritesSearchTerm.trim() === "") {
return true;
return false;
}
return this.sectionsWithFavourites.some((section) => section.favourites.length > 0);
return !this.sectionsWithFavourites.some((section) => section.favourites.length > 0);
},
collapsedFavouritePreview() {
const out = [];
@@ -719,6 +881,26 @@ export default {
collapsedAnnounceNodesPreview() {
return this.nodesOrderedByLatestAnnounce.slice(0, 5);
},
flatVisibleFavouriteDestinationHashes() {
const out = [];
for (const section of this.sectionsWithFavourites) {
for (const fav of section.favourites) {
out.push(fav.destination_hash);
}
}
return out;
},
flatVisibleAnnounceDestinationHashes() {
return this.searchedNodes.map((n) => n.destination_hash);
},
allVisibleFavouritesSelected() {
const ids = this.flatVisibleFavouriteDestinationHashes;
return ids.length > 0 && ids.every((id) => this.selectedFavouriteHashes.includes(id));
},
allVisibleAnnouncesSelected() {
const ids = this.flatVisibleAnnounceDestinationHashes;
return ids.length > 0 && ids.every((id) => this.selectedAnnounceHashes.includes(id));
},
},
watch: {
favourites: {
@@ -727,6 +909,11 @@ export default {
},
deep: true,
},
tab() {
this.exitFavouritesSelectionMode();
this.exitAnnouncesSelectionMode();
this.favouriteBulkMoveMenuOpen = false;
},
},
mounted() {
this.loadFavouriteLayout();
@@ -751,6 +938,151 @@ export default {
}
},
methods: {
toggleFavouritesSelectionMode() {
this.favouritesSelectionMode = !this.favouritesSelectionMode;
if (!this.favouritesSelectionMode) {
this.selectedFavouriteHashes = [];
}
this.favouriteBulkMoveMenuOpen = false;
},
toggleAnnouncesSelectionMode() {
this.announcesSelectionMode = !this.announcesSelectionMode;
if (!this.announcesSelectionMode) {
this.selectedAnnounceHashes = [];
}
},
exitFavouritesSelectionMode() {
this.favouritesSelectionMode = false;
this.selectedFavouriteHashes = [];
this.favouriteBulkMoveMenuOpen = false;
},
exitAnnouncesSelectionMode() {
this.announcesSelectionMode = false;
this.selectedAnnounceHashes = [];
},
toggleSelectFavourite(hash) {
const i = this.selectedFavouriteHashes.indexOf(hash);
if (i >= 0) {
this.selectedFavouriteHashes.splice(i, 1);
} else {
this.selectedFavouriteHashes.push(hash);
}
},
toggleSelectAnnounce(hash) {
const i = this.selectedAnnounceHashes.indexOf(hash);
if (i >= 0) {
this.selectedAnnounceHashes.splice(i, 1);
} else {
this.selectedAnnounceHashes.push(hash);
}
},
toggleSelectAllVisibleFavourites() {
const ids = this.flatVisibleFavouriteDestinationHashes;
if (ids.length === 0) {
return;
}
if (ids.every((id) => this.selectedFavouriteHashes.includes(id))) {
this.selectedFavouriteHashes = this.selectedFavouriteHashes.filter((h) => !ids.includes(h));
} else {
this.selectedFavouriteHashes = [...new Set([...this.selectedFavouriteHashes, ...ids])];
}
},
toggleSelectAllVisibleAnnounces() {
const ids = this.flatVisibleAnnounceDestinationHashes;
if (ids.length === 0) {
return;
}
if (ids.every((id) => this.selectedAnnounceHashes.includes(id))) {
this.selectedAnnounceHashes = this.selectedAnnounceHashes.filter((h) => !ids.includes(h));
} else {
this.selectedAnnounceHashes = [...new Set([...this.selectedAnnounceHashes, ...ids])];
}
},
onFavouriteRowActivate(favourite) {
if (this.isBlocked(favourite.destination_hash)) {
return;
}
if (this.favouritesSelectionMode) {
this.toggleSelectFavourite(favourite.destination_hash);
return;
}
this.onFavouriteClick(favourite);
},
onAnnounceRowActivate(node) {
if (this.isBlocked(node.identity_hash || node.destination_hash)) {
return;
}
if (this.announcesSelectionMode) {
this.toggleSelectAnnounce(node.destination_hash);
return;
}
this.onNodeClick(node);
},
isFavouriteRowDragging(destinationHash) {
return this.draggingFavouriteHashes.includes(destinationHash);
},
closeFavouriteBulkMoveMenu() {
this.favouriteBulkMoveMenuOpen = false;
},
bulkMoveSelectedFavouritesToSection(sectionId) {
if (!sectionId || this.selectedFavouriteHashes.length === 0) {
this.closeFavouriteBulkMoveMenu();
return;
}
this.moveFavouritesToSection([...this.selectedFavouriteHashes], sectionId);
this.closeFavouriteBulkMoveMenu();
this.exitFavouritesSelectionMode();
},
async bulkRemoveSelectedFavourites() {
const hashes = [...this.selectedFavouriteHashes];
if (hashes.length === 0) {
return;
}
if (
!(await DialogUtils.confirm(
this.$t("nomadnet.bulk_remove_favourites_confirm", { count: hashes.length })
))
) {
return;
}
this.$emit("bulk-remove-favourites", hashes);
this.exitFavouritesSelectionMode();
},
async bulkBanishSelectedAnnounces() {
const nodes = this.selectedAnnounceHashes
.map((h) => this.nodes[h])
.filter((n) => n && !this.isBlocked(n.identity_hash) && !this.isBlocked(n.destination_hash));
if (nodes.length === 0) {
return;
}
if (!(await DialogUtils.confirm(this.$t("nomadnet.bulk_block_confirm", { count: nodes.length })))) {
return;
}
try {
for (const node of nodes) {
await window.api.post("/api/v1/blocked-destinations", {
destination_hash: node.identity_hash,
});
}
GlobalEmitter.emit("block-status-changed");
ToastUtils.success(this.$t("nomadnet.bulk_block_done", { count: nodes.length }));
} catch (e) {
DialogUtils.alert(this.$t("nomadnet.failed_to_block_node"));
console.error(e);
}
this.exitAnnouncesSelectionMode();
},
bulkAddSelectedAnnouncesToFavourites() {
const nodes = this.selectedAnnounceHashes
.map((h) => this.nodes[h])
.filter((n) => n && !this.isFavourite(n.destination_hash));
if (nodes.length === 0) {
ToastUtils.info(this.$t("nomadnet.bulk_nothing_to_add_favourites"));
return;
}
this.$emit("bulk-add-favourites", nodes);
this.exitAnnouncesSelectionMode();
},
startEditingSection(section) {
this.editingSectionId = section.id;
this.editingSectionName = section.name;
@@ -958,14 +1290,28 @@ export default {
this.$emit("remove-favourite", favourite);
},
onFavouriteDragStart(event, favourite, sectionId) {
let hashes;
if (
this.favouritesSelectionMode &&
this.selectedFavouriteHashes.includes(favourite.destination_hash) &&
this.selectedFavouriteHashes.length > 1
) {
hashes = [...this.selectedFavouriteHashes];
} else {
hashes = [favourite.destination_hash];
}
try {
if (event?.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", favourite.destination_hash);
if (hashes.length > 1) {
event.dataTransfer.setData("application/x-meshchat-nomad-favs", JSON.stringify(hashes));
}
}
} catch {
// ignore for browsers that prevent setting drag meta
}
this.draggingFavouriteHashes = hashes;
this.draggingFavouriteHash = favourite.destination_hash;
this.draggingFavouriteSectionId = sectionId;
},
@@ -975,13 +1321,23 @@ export default {
}
},
onFavouriteDrop(event, targetSectionId, targetFavourite) {
if (!this.draggingFavouriteHash || this.draggingFavouriteHash === targetFavourite.destination_hash) {
const hashes =
this.draggingFavouriteHashes.length > 0
? [...this.draggingFavouriteHashes]
: this.draggingFavouriteHash
? [this.draggingFavouriteHash]
: [];
if (!hashes.length) {
return;
}
this.moveFavouriteToSection(this.draggingFavouriteHash, targetSectionId, targetFavourite.destination_hash);
if (hashes.includes(targetFavourite.destination_hash)) {
return;
}
this.moveFavouritesToSection(hashes, targetSectionId, targetFavourite.destination_hash);
},
onFavouriteDragEnd() {
this.draggingFavouriteHash = null;
this.draggingFavouriteHashes = [];
this.draggingFavouriteSectionId = null;
this.dragOverSectionId = null;
},
@@ -992,10 +1348,16 @@ export default {
this.dragOverSectionId = null;
},
onDropOnSection(sectionId) {
if (!this.draggingFavouriteHash) {
const hashes =
this.draggingFavouriteHashes.length > 0
? [...this.draggingFavouriteHashes]
: this.draggingFavouriteHash
? [this.draggingFavouriteHash]
: [];
if (!hashes.length) {
return;
}
this.moveFavouriteToSection(this.draggingFavouriteHash, sectionId);
this.moveFavouritesToSection(hashes, sectionId);
},
onSectionDragStart(sectionId) {
this.draggingSectionId = sectionId;
@@ -1029,32 +1391,42 @@ export default {
this.draggingSectionOverId = null;
},
moveFavouriteToSection(hash, targetSectionId, beforeHash = null) {
if (!hash || !targetSectionId) {
this.moveFavouritesToSection([hash], targetSectionId, beforeHash);
},
moveFavouritesToSection(hashes, targetSectionId, beforeHash = null) {
const unique = [...new Set((hashes || []).filter(Boolean))];
if (!unique.length || !targetSectionId) {
return;
}
const updated = {};
Object.keys(this.favouritesBySection).forEach((sectionKey) => {
updated[sectionKey] = (this.favouritesBySection[sectionKey] || []).filter((value) => value !== hash);
updated[sectionKey] = [...(this.favouritesBySection[sectionKey] || [])].filter(
(value) => !unique.includes(value)
);
});
if (!updated[targetSectionId]) {
updated[targetSectionId] = [];
}
const targetList = [...updated[targetSectionId]];
if (beforeHash) {
let targetList = [...updated[targetSectionId]];
if (beforeHash && !unique.includes(beforeHash)) {
const insertIndex = targetList.indexOf(beforeHash);
if (insertIndex === -1) {
targetList.push(hash);
targetList.push(...unique);
} else {
targetList.splice(insertIndex, 0, hash);
targetList.splice(insertIndex, 0, ...unique);
}
} else {
targetList.push(hash);
targetList.push(...unique);
}
updated[targetSectionId] = targetList;
this.favouritesBySection = updated;
this.persistFavouriteLayout();
this.draggingFavouriteHash = null;
this.draggingFavouriteHashes = [];
this.draggingFavouriteSectionId = null;
this.dragOverSectionId = null;
},
@@ -1120,6 +1492,7 @@ export default {
this.favouriteContextMenu.show = false;
this.sectionContextMenu.show = false;
this.announceContextMenu.show = false;
this.favouriteBulkMoveMenuOpen = false;
},
openAnnounceContextMenu(event, node) {
this.announceContextMenu = {
@@ -1331,6 +1704,12 @@ export default {
@apply border-blue-500 dark:border-blue-400 bg-blue-50/70 dark:bg-blue-900/30;
}
.empty-state {
@apply flex flex-col items-center justify-center text-center gap-2 text-gray-500 dark:text-gray-400 mt-20;
@apply flex flex-col items-center justify-center text-center gap-2 text-gray-500 dark:text-gray-400;
}
.empty-state--compact {
@apply justify-center py-3;
}
.empty-state--panel {
@apply min-h-[min(50vh,18rem)] py-10 sm:min-h-[min(45vh,20rem)];
}
</style>
@@ -721,6 +721,37 @@
</div>
</section>
<section
v-if="isMeshChatXAndroid"
v-show="matchesSearch(...sectionKeywords.android)"
class="settings-section break-inside-avoid"
>
<header class="settings-section__header">
<div>
<div class="settings-section__eyebrow">Android</div>
<h2>{{ $t("settings.share_apk_heading") }}</h2>
<p>{{ $t("settings.share_apk_desc") }}</p>
</div>
</header>
<div class="settings-section__body space-y-4">
<button
type="button"
class="btn-maintenance border-blue-200 dark:border-blue-900/30 text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100 dark:hover:bg-blue-900/20"
@click="shareAndroidApk"
>
<div class="flex flex-col items-start text-left">
<div class="font-bold flex items-center gap-2">
<MaterialDesignIcon icon-name="share-variant" class="size-4" />
{{ $t("settings.share_apk") }}
</div>
<div class="text-xs opacity-80">
{{ $t("settings.share_apk_short_hint") }}
</div>
</div>
</button>
</div>
</section>
<!-- Page Archiver -->
<section
v-show="matchesSearch(...sectionKeywords.archiver)"
@@ -2449,6 +2480,7 @@ import ShortcutRecorder from "./ShortcutRecorder.vue";
import SettingsSectionBlock from "./SettingsSectionBlock.vue";
import KeyboardShortcuts from "../../js/KeyboardShortcuts";
import ElectronUtils from "../../js/ElectronUtils";
import AndroidBridge from "../../js/rnode/AndroidBridge";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import StickerPacksManager from "../stickers/StickerPacksManager.vue";
import GlobalState from "../../js/GlobalState";
@@ -2677,6 +2709,16 @@ export default {
"app.desktop_hardware_acceleration_enabled",
"app.desktop_hardware_acceleration_enabled_description",
],
android: [
"Android",
"APK",
"Bluetooth",
"Nearby Share",
"settings.share_apk_heading",
"settings.share_apk_desc",
"settings.share_apk",
"settings.share_apk_short_hint",
],
archiver: ["Browsing", "Page Archiver", "archiver", "archive", "versions", "storage", "flush"],
nomadRenderer: [
"NomadNet",
@@ -2881,6 +2923,14 @@ export default {
const c = this.config?.lxmf_inbound_stamp_cost;
return (typeof c === "number" ? c : Number(c) || 0) > 0;
},
isMeshChatXAndroid() {
return (
typeof window !== "undefined" &&
window.MeshChatXAndroid &&
typeof window.MeshChatXAndroid.getPlatform === "function" &&
window.MeshChatXAndroid.getPlatform() === "android"
);
},
},
beforeUnmount() {
// stop listening for websocket messages
@@ -2943,6 +2993,12 @@ export default {
clearSettingsSearch() {
this.searchQuery = "";
},
shareAndroidApk() {
const bridge = new AndroidBridge();
if (!bridge.shareApk()) {
ToastUtils.error(this.$t("settings.share_apk_failed"));
}
},
matchesSearch(...texts) {
return matchesSettingSearch(texts, (k) => this.$t(k), this.searchQuery);
},
@@ -3875,14 +3931,9 @@ export default {
const response = await window.api.get("/api/v1/maintenance/messages/export");
const messages = response.data.messages;
const dataStr = JSON.stringify({ messages }, null, 2);
const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
const blob = new Blob([dataStr], { type: "application/json" });
const exportFileDefaultName = `meshchat_messages_${new Date().toISOString().slice(0, 10)}.json`;
const linkElement = document.createElement("a");
linkElement.setAttribute("href", dataUri);
linkElement.setAttribute("download", exportFileDefaultName);
linkElement.click();
await DownloadUtils.downloadFile(exportFileDefaultName, blob);
} catch {
ToastUtils.error(this.$t("common.error"));
}
@@ -3916,12 +3967,9 @@ export default {
try {
const response = await window.api.get("/api/v1/lxmf/folders/export");
const dataStr = JSON.stringify(response.data, null, 2);
const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
const blob = new Blob([dataStr], { type: "application/json" });
const exportFileDefaultName = `meshchat_folders_${new Date().toISOString().slice(0, 10)}.json`;
const linkElement = document.createElement("a");
linkElement.setAttribute("href", dataUri);
linkElement.setAttribute("download", exportFileDefaultName);
linkElement.click();
await DownloadUtils.downloadFile(exportFileDefaultName, blob);
ToastUtils.success(this.$t("settings.folders_exported"));
} catch {
ToastUtils.error(this.$t("settings.failed_export_folders"));