mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-05-13 17:53:06 +00:00
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:
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user