refactor(frontend): streamline file download methods and update user interface elements across components

This commit is contained in:
Ivan
2026-04-24 14:33:48 -05:00
parent 35937ec247
commit 7a02ca3492
8 changed files with 290 additions and 78 deletions
@@ -4,7 +4,7 @@
<div
v-if="customImage"
class="rounded-full overflow-hidden shrink-0 flex items-center justify-center"
:class="iconClass || 'size-6'"
:class="resolvedShellClass"
:style="iconStyle"
>
<img :src="customImage" class="w-full h-full object-cover" />
@@ -13,14 +13,14 @@
v-else-if="iconName"
class="p-[10%] rounded-full shrink-0 flex items-center justify-center"
:style="[iconStyle, { 'background-color': finalBackgroundColor }]"
:class="iconClass || 'size-6'"
:class="resolvedShellClass"
>
<MaterialDesignIcon :icon-name="iconName" class="size-full" :style="{ color: finalForegroundColor }" />
</div>
<div
v-else
class="bg-gray-100 dark:bg-zinc-800 text-gray-400 dark:text-zinc-500 p-[15%] rounded-full shrink-0 flex items-center justify-center border border-gray-200 dark:border-zinc-700"
:class="iconClass || 'size-6'"
:class="resolvedShellClass"
:style="iconStyle"
>
<MaterialDesignIcon icon-name="account" class="w-full h-full" />
@@ -61,6 +61,19 @@ export default {
},
},
computed: {
resolvedShellClass() {
const extra = (this.iconClass || "").trim();
if (
/\bsize-[\w.]+\b/.test(extra) ||
/\bw-[\w.]+\b/.test(extra) ||
/\bh-[\w.]+\b/.test(extra) ||
/\bmin-w-/.test(extra) ||
/\bmin-h-/.test(extra)
) {
return extra;
}
return ["size-6", extra].filter(Boolean).join(" ").trim();
},
finalForegroundColor() {
return this.iconForegroundColour && this.iconForegroundColour !== ""
? this.iconForegroundColour
@@ -233,6 +233,7 @@
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import Utils from "../../js/Utils";
import DownloadUtils from "../../js/DownloadUtils";
import MicronParser from "../../js/MicronParser.js";
import GlobalState from "../../js/GlobalState.js";
import { renderNomadPageByPath } from "../../js/NomadPageRenderer.js";
@@ -504,17 +505,9 @@ export default {
}
}
},
downloadTextAsFile(content, filename) {
async downloadTextAsFile(content, filename) {
const blob = new Blob([content ?? ""], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
await DownloadUtils.downloadFile(filename, blob);
},
muExportBasename(archive) {
let base = (archive.page_path || "page").split("/").pop() || "page";
@@ -894,7 +894,7 @@
<span
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono ml-2 shrink-0"
>
{{ formatTimeAgo(announce.updated_at) }} ago
{{ formatTimeAgo(announce.updated_at) }}
</span>
</div>
<div class="flex items-center justify-between mt-1">
@@ -995,7 +995,7 @@ export default {
const response = await window.api.post("/api/v1/reticulum/interfaces/export");
// download file to browser
DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
await DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
} catch (e) {
DialogUtils.alert(this.$t("interfaces.failed_export_all"));
console.error(e);
@@ -1009,7 +1009,7 @@ export default {
});
// download file to browser
DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
await DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
} catch (e) {
DialogUtils.alert(this.$t("interfaces.failed_export_single"));
console.error(e);
@@ -62,7 +62,9 @@
>
<div class="w-full max-w-md bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Share Contact</h3>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">
{{ $t("messages.share_contact_modal_title") }}
</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
@@ -77,7 +79,7 @@
<input
v-model="contactsSearch"
type="text"
placeholder="Search contacts..."
:placeholder="$t('messages.share_contact_search_placeholder')"
class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
/>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
@@ -94,10 +96,9 @@
@click="shareContact(contact)"
>
<div
class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center shrink-0"
>
<MaterialDesignIcon icon-name="account" class="size-6" />
</div>
class="h-10 w-10 shrink-0 rounded-full border-2 border-dashed border-gray-300 dark:border-zinc-600 bg-gray-50 dark:bg-zinc-800/80"
aria-hidden="true"
/>
<div class="min-w-0">
<div class="font-bold text-gray-900 dark:text-white truncate">
{{ contact.name }}
@@ -1487,6 +1488,7 @@
<script>
import Utils from "../../js/Utils";
import DownloadUtils from "../../js/DownloadUtils";
import { clampFloatingToViewport } from "../../js/clampFloatingToViewport.js";
import { isNearBottom, scrollContainerToBottom, shouldLoadPreviousMessages } from "./conversationScroll.js";
import {
@@ -3766,33 +3768,7 @@ export default {
return this.imageGroupSortedChron(items).map((it) => this.lxmfImageUrl(it.lxmf_message.hash));
},
downloadFileFromBase64: async function (fileName, fileBytesBase64) {
// create blob from base64 encoded file bytes
const byteCharacters = atob(fileBytesBase64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray]);
// create object url for blob
const objectUrl = URL.createObjectURL(blob);
// create link element to download blob
const link = document.createElement("a");
link.href = objectUrl;
link.download = fileName;
link.style.display = "none";
document.body.append(link);
// click link to download file in browser
link.click();
// link element is no longer needed
link.remove();
// revoke object url to clear memory
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
DownloadUtils.downloadFromBase64(fileName, fileBytesBase64);
},
async processAudioForSelectedPeerChatItems() {
for (const chatItem of this.selectedPeerChatItems) {
@@ -450,6 +450,7 @@ import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
import Utils from "../../js/Utils";
import DownloadUtils from "../../js/DownloadUtils";
import ToastUtils from "../../js/ToastUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import IconButton from "../IconButton.vue";
@@ -1762,33 +1763,7 @@ export default {
ToastUtils.warning(this.$t("nomadnet.unsupported_url") + url);
},
downloadFileFromBase64: async function (fileName, fileBytesBase64) {
// create blob from base64 encoded file bytes
const byteCharacters = atob(fileBytesBase64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray]);
// create object url for blob
const objectUrl = URL.createObjectURL(blob);
// create link element to download blob
const link = document.createElement("a");
link.href = objectUrl;
link.download = fileName;
link.style.display = "none";
document.body.append(link);
// click link to download file in browser
link.click();
// link element is no longer needed
link.remove();
// revoke object url to clear memory
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
DownloadUtils.downloadFromBase64(fileName, fileBytesBase64);
},
formatBytesPerSecond: function (bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
@@ -358,6 +358,10 @@
<MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
Rename Section
</ContextMenuItem>
<ContextMenuItem @click="exportSectionFavouritesFromContext">
<MaterialDesignIcon icon-name="file-export" class="size-4 text-gray-400" />
{{ $t("nomadnet.export_section_favourites") }}
</ContextMenuItem>
<ContextMenuItem
:item-class="
'text-red-600 dark:text-red-400' +
@@ -423,7 +427,9 @@
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 flex flex-col gap-0.5">
<span class="truncate">{{
$t("nomadnet.announced_time_ago", { time: formatTimeAgo(node.updated_at) })
$t("nomadnet.announced_time_ago", {
time: formatTimeAgoForI18n(node.updated_at),
})
}}</span>
<span
class="cursor-pointer hover:text-blue-500 dark:hover:text-blue-400 inline-flex items-center"
@@ -534,6 +540,7 @@ import DialogUtils from "../../js/DialogUtils";
import GlobalState from "../../js/GlobalState";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
export default {
name: "NomadNetworkSidebar",
@@ -730,11 +737,18 @@ export default {
};
this._smUpResize();
this._smUpMql.addEventListener("change", this._smUpResize);
this._onNomadnetFavouritesLayoutImported = () => {
this.loadFavouriteLayout();
};
GlobalEmitter.on("nomadnet-favourites-layout-imported", this._onNomadnetFavouritesLayoutImported);
},
unmounted() {
if (this._smUpMql && this._smUpResize) {
this._smUpMql.removeEventListener("change", this._smUpResize);
}
if (this._onNomadnetFavouritesLayoutImported) {
GlobalEmitter.off("nomadnet-favourites-layout-imported", this._onNomadnetFavouritesLayoutImported);
}
},
methods: {
startEditingSection(section) {
@@ -1067,6 +1081,41 @@ export default {
};
this.favouriteContextMenu.show = false;
},
async exportSectionFavouritesFromContext() {
const sid = this.sectionContextMenu.sectionId;
if (!sid) {
this.closeContextMenus();
return;
}
const section = this.sections.find((s) => s.id === sid);
this.closeContextMenus();
if (!section) {
return;
}
const hashes = this.favouritesBySection[sid] || [];
const payload = {
format: "meshchatx/nomadnet_favourites_section/v1",
exported_at: new Date().toISOString(),
section: {
id: section.id,
name: section.name,
collapsed: section.collapsed === true,
},
destination_hashes: hashes.filter((h) => typeof h === "string"),
};
const slug = (section.name || "section")
.replace(/[^a-z0-9]+/gi, "_")
.replace(/^_|_$/g, "")
.slice(0, 48);
const namePart = slug || "section";
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
try {
await DownloadUtils.downloadFile(`nomadnet_favourites_section_${namePart}.json`, blob);
ToastUtils.success(this.$t("nomadnet.section_favourites_exported"));
} catch {
ToastUtils.error(this.$t("nomadnet.section_favourites_export_failed"));
}
},
closeContextMenus() {
this.favouriteContextMenu.show = false;
this.sectionContextMenu.show = false;
@@ -1227,6 +1276,9 @@ export default {
formatTimeAgo: function (datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
formatTimeAgoForI18n: function (datetimeString) {
return Utils.formatTimeAgoForI18n(datetimeString);
},
formatDestinationHash: function (destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
@@ -622,6 +622,43 @@
@change="importFolders"
/>
</div>
<div class="grid grid-cols-2 gap-3 mt-2 pt-4 border-t border-gray-100 dark:border-zinc-800">
<button
type="button"
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-teal-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-teal-500 transition group"
@click="exportNomadnetFavouritesLayout"
>
<MaterialDesignIcon
icon-name="file-export"
class="size-6 text-teal-500 group-hover:scale-110 transition"
/>
<div class="text-sm font-bold">
{{ $t("maintenance.export_nomadnet_favourites") }}
</div>
</button>
<button
type="button"
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-cyan-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-cyan-500 transition group"
@click="triggerNomadnetFavouritesImport"
>
<MaterialDesignIcon
icon-name="import"
class="size-6 text-cyan-500 group-hover:scale-110 transition"
/>
<div class="text-sm font-bold">
{{ $t("maintenance.import_nomadnet_favourites") }}
</div>
</button>
<input
ref="nomadnetFavouritesImportFile"
type="file"
accept=".json"
class="hidden"
@change="importNomadnetFavouritesLayoutFile"
/>
</div>
</div>
</section>
@@ -2077,7 +2114,7 @@
<div class="text-xs text-gray-600 dark:text-gray-400">
<span v-if="config.lxmf_preferred_propagation_node_last_synced_at">{{
$t("app.last_synced", {
time: formatSecondsAgo(
time: formatSecondsAgoForI18n(
config.lxmf_preferred_propagation_node_last_synced_at
),
})
@@ -2271,6 +2308,8 @@ import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import Toggle from "../forms/Toggle.vue";
import ShortcutRecorder from "./ShortcutRecorder.vue";
@@ -2460,6 +2499,8 @@ export default {
"maintenance.export_messages_desc",
"maintenance.import_messages",
"maintenance.import_messages_desc",
"maintenance.export_nomadnet_favourites",
"maintenance.import_nomadnet_favourites",
"Automatic Backup Limit",
"Export Folders",
"Import Folders",
@@ -3703,9 +3744,171 @@ export default {
// Reset input
event.target.value = "";
},
normalizeNomadnetFavouritesLayoutShape(layout) {
if (!layout || typeof layout !== "object" || !Array.isArray(layout.sections)) {
return null;
}
const favouritesBySection =
layout.favouritesBySection && typeof layout.favouritesBySection === "object"
? layout.favouritesBySection
: {};
const sectionOrder = Array.isArray(layout.sectionOrder)
? layout.sectionOrder
: layout.sections.map((s) => s && s.id).filter(Boolean);
const sections = layout.sections
.filter((s) => s && typeof s.id === "string")
.map((s) => ({
id: s.id,
name: typeof s.name === "string" ? s.name : "",
collapsed: s.collapsed === true,
}));
const sanitizedMap = {};
for (const k of Object.keys(favouritesBySection)) {
const arr = favouritesBySection[k];
if (Array.isArray(arr)) {
sanitizedMap[k] = arr.filter((h) => typeof h === "string");
}
}
return { sections, sectionOrder, favouritesBySection: sanitizedMap };
},
parseNomadnetFavouritesImportData(data) {
if (!data || typeof data !== "object") {
return null;
}
if (data.format === "meshchatx/nomadnet_favourites/v1" && data.layout && typeof data.layout === "object") {
const layout = this.normalizeNomadnetFavouritesLayoutShape(data.layout);
return layout ? { kind: "full", layout } : null;
}
if (data.format === "meshchatx/nomadnet_favourites_section/v1") {
const sec = data.section;
if (!sec || typeof sec.id !== "string") {
return null;
}
return { kind: "section", payload: data };
}
const layout = this.normalizeNomadnetFavouritesLayoutShape(data);
return layout ? { kind: "full", layout } : null;
},
mergeNomadnetFavouritesSectionImport(payload) {
const sec = payload.section;
const hashes = Array.isArray(payload.destination_hashes)
? payload.destination_hashes.filter((h) => typeof h === "string")
: [];
let raw = null;
try {
raw = localStorage.getItem("meshchat.nomadnet.favourites.layout");
} catch {
raw = null;
}
let base = { sections: [], sectionOrder: [], favouritesBySection: {} };
if (raw) {
try {
const parsed = JSON.parse(raw);
const normalized = this.normalizeNomadnetFavouritesLayoutShape(parsed);
if (normalized) {
base = normalized;
}
} catch {
// keep default base
}
}
const sections = [...base.sections];
const sectionOrder = [...base.sectionOrder];
const favouritesBySection = { ...base.favouritesBySection };
const idx = sections.findIndex((s) => s.id === sec.id);
const sectionObj = {
id: sec.id,
name:
typeof sec.name === "string" && sec.name.trim() !== "" ? sec.name : this.$t("nomadnet.favourites"),
collapsed: sec.collapsed === true,
};
if (idx === -1) {
sections.push(sectionObj);
if (!sectionOrder.includes(sec.id)) {
sectionOrder.push(sec.id);
}
} else {
sections[idx] = { ...sections[idx], ...sectionObj };
}
favouritesBySection[sec.id] = hashes;
const merged = this.normalizeNomadnetFavouritesLayoutShape({
sections,
sectionOrder,
favouritesBySection,
});
if (!merged) {
throw new Error("invalid layout");
}
localStorage.setItem("meshchat.nomadnet.favourites.layout", JSON.stringify(merged));
},
async exportNomadnetFavouritesLayout() {
let layout = { sections: [], sectionOrder: [], favouritesBySection: {} };
try {
const raw = localStorage.getItem("meshchat.nomadnet.favourites.layout");
if (raw) {
const parsed = JSON.parse(raw);
const normalized = this.normalizeNomadnetFavouritesLayoutShape(parsed);
if (normalized) {
layout = normalized;
}
}
} catch {
// keep empty layout
}
const body = {
format: "meshchatx/nomadnet_favourites/v1",
exported_at: new Date().toISOString(),
layout,
};
const blob = new Blob([JSON.stringify(body, null, 2)], { type: "application/json" });
try {
await DownloadUtils.downloadFile(
`meshchat_nomadnet_favourites_${new Date().toISOString().slice(0, 10)}.json`,
blob
);
ToastUtils.success(this.$t("maintenance.nomadnet_favourites_exported"));
} catch {
ToastUtils.error(this.$t("maintenance.nomadnet_favourites_export_failed"));
}
},
triggerNomadnetFavouritesImport() {
this.$refs.nomadnetFavouritesImportFile.click();
},
importNomadnetFavouritesLayoutFile(event) {
const file = event.target.files[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
const parsed = this.parseNomadnetFavouritesImportData(data);
if (!parsed) {
throw new Error("invalid file");
}
if (parsed.kind === "full") {
localStorage.setItem("meshchat.nomadnet.favourites.layout", JSON.stringify(parsed.layout));
} else if (parsed.kind === "section") {
this.mergeNomadnetFavouritesSectionImport(parsed.payload);
} else {
throw new Error("invalid file");
}
GlobalEmitter.emit("nomadnet-favourites-layout-imported");
ToastUtils.success(this.$t("maintenance.nomadnet_favourites_imported"));
} catch {
ToastUtils.error(this.$t("maintenance.nomadnet_favourites_import_failed"));
}
};
reader.readAsText(file);
event.target.value = "";
},
formatSecondsAgo: function (seconds) {
return Utils.formatSecondsAgo(seconds);
},
formatSecondsAgoForI18n: function (seconds) {
return Utils.formatSecondsAgoForI18n(seconds);
},
},
};
</script>