feat(router): add new route for reticulum configuration editor and update command palette navigation

This commit is contained in:
Ivan
2026-04-17 23:28:11 -05:00
parent 42b8749704
commit cb72691a77
34 changed files with 4249 additions and 915 deletions

View File

@@ -39,26 +39,26 @@
<MaterialDesignIcon :icon-name="isSidebarOpen ? 'close' : 'menu'" class="size-6" />
</button>
<div
class="my-auto mr-2 hidden w-14 shrink-0 cursor-pointer overflow-hidden rounded-xl sm:flex"
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-14 w-14 object-contain p-1" :src="logoUrl" alt="" />
<img class="h-10 w-10 object-contain p-1 sm:h-14 sm:w-14" :src="logoUrl" alt="" />
</div>
<div class="my-auto">
<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>
<div class="hidden sm:block text-sm text-gray-600 dark:text-zinc-300">
<div class="text-sm text-gray-600 dark:text-zinc-300">
{{ $t("app.tagline") }}
</div>
</div>
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
<button
type="button"
class="relative rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
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"
:title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleTheme"
>
@@ -67,8 +67,22 @@
class="w-5 h-5 sm:w-6 sm:h-6"
/>
</button>
<LanguageSelector @language-change="onLanguageChange" />
<LanguageSelector class="hidden sm:block" @language-change="onLanguageChange" />
<NotificationBell />
<button
type="button"
class="sm:hidden rounded-full p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors relative"
:title="$t('app.messages')"
@click="$router.push({ name: 'messages' })"
>
<MaterialDesignIcon icon-name="message-text" class="w-5 h-5" />
<span
v-if="unreadConversationsCount > 0"
class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center leading-none"
>
{{ unreadConversationsCount > 99 ? "99+" : unreadConversationsCount }}
</span>
</button>
<button
type="button"
class="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"
@@ -131,9 +145,9 @@
<div
class="flex h-full w-full flex-col overflow-y-auto border-r border-gray-200 bg-white dark:border-zinc-800 dark:bg-zinc-950 pt-16 sm:pt-0"
>
<!-- toggle button for desktop (h-12 aligns with Messages/Nomad collapse rows) -->
<!-- toggle button for desktop (h-10 aligns with Messages/Nomad collapse rows) -->
<div
class="hidden sm:flex h-12 shrink-0 items-center justify-end border-b border-gray-200 dark:border-zinc-800 px-2"
class="hidden sm:flex h-10 shrink-0 items-center justify-end border-b border-gray-200 dark:border-zinc-800 px-2"
>
<button
type="button"
@@ -147,6 +161,27 @@
</button>
</div>
<!-- mobile-only quick settings row (theme + language) -->
<div
class="sm:hidden flex items-center justify-between gap-2 px-3 py-2 border-b border-gray-200 dark:border-zinc-800"
>
<button
type="button"
class="flex items-center gap-2 flex-1 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-700 dark:text-zinc-200 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleTheme"
>
<MaterialDesignIcon
:icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
class="w-5 h-5 shrink-0"
/>
<span class="truncate">{{
config?.theme === "dark" ? $t("app.light_theme") : $t("app.dark_theme")
}}</span>
</button>
<LanguageSelector @language-change="onLanguageChange" />
</div>
<!-- navigation -->
<div class="flex-1">
<ul class="py-3 pr-2 space-y-1">
@@ -750,6 +785,7 @@ export default {
if (this.endedTimeout) clearTimeout(this.endedTimeout);
this.stopRingtone();
this.toneGenerator.stop();
window.removeEventListener("meshchatx-intent-uri", this.onAndroidIntentUri);
},
mounted() {
try {
@@ -767,6 +803,7 @@ export default {
this.handleProtocolLink(url);
});
}
window.addEventListener("meshchatx-intent-uri", this.onAndroidIntentUri);
},
methods: {
startShellAuthWatch() {
@@ -1645,10 +1682,33 @@ export default {
});
this.$router.push("/messages");
},
onAndroidIntentUri(event) {
const uri = event?.detail;
if (typeof uri !== "string" || uri.trim() === "") {
return;
}
this.handleProtocolLink(uri.trim());
},
handleProtocolLink(url) {
try {
const normalizedUrl = String(url || "").trim();
if (!normalizedUrl) {
return;
}
if (/^lxm(a|f)?:\/\//i.test(normalizedUrl)) {
WebSocketConnection.send(
JSON.stringify({
type: "lxm.ingest_uri",
uri: normalizedUrl,
})
);
}
// lxma://<hash>:<pubkey> or lxmf://<hash> or rns://<hash>
const cleanUrl = url.replace("lxma://", "").replace("lxmf://", "").replace("rns://", "");
const cleanUrl = normalizedUrl
.replace(/^lxma:\/\//i, "")
.replace(/^lxmf:\/\//i, "")
.replace(/^rns:\/\//i, "");
const hash = cleanUrl.split(":")[0].split("/")[0].replace("/", "");
if (hash && hash.length === 32) {
this.$router.push({

View File

@@ -265,6 +265,14 @@ export default {
type: "navigation",
route: { name: "micron-editor" },
},
{
id: "nav-reticulum-config-editor",
title: "nav_reticulum_config_editor",
description: "nav_reticulum_config_editor_desc",
icon: "file-cog",
type: "navigation",
route: { name: "reticulum-config-editor" },
},
{
id: "nav-rnode-flasher",
title: "nav_rnode_flasher",

View File

@@ -1,12 +1,14 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
<div
class="fixed bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-[100] flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"
>
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
class="pointer-events-auto flex items-center p-4 min-w-[300px] max-w-md rounded-xl shadow-lg border backdrop-blur-md transition-all duration-300"
class="pointer-events-auto flex items-center p-4 w-full sm:min-w-[300px] sm:max-w-md rounded-xl shadow-lg border backdrop-blur-md transition-all duration-300"
:class="toastClass(toast.type)"
>
<!-- icon -->
@@ -166,10 +168,18 @@ export default {
}
.toast-enter-from {
opacity: 0;
transform: translateX(30px);
transform: translateY(30px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(30px);
transform: translateY(30px);
}
@media (min-width: 640px) {
.toast-enter-from {
transform: translateX(30px);
}
.toast-leave-to {
transform: translateX(30px);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -38,56 +38,46 @@
</div>
<div
class="flex w-full flex-1 flex-wrap justify-stretch gap-2 sm:justify-end sm:gap-3 md:w-auto"
class="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-1 sm:flex-wrap sm:justify-end sm:gap-3"
>
<button
type="button"
class="secondary-chip flex-1 min-[480px]:flex-none min-h-[44px] sm:min-h-0 justify-center"
@click="showTutorial"
>
<v-icon icon="mdi-help-circle" size="20" class="mr-2"></v-icon>
{{ $t("app.tutorial_title") }}
<button type="button" class="about-action-btn secondary-chip" @click="showTutorial">
<v-icon icon="mdi-help-circle" size="20" class="mr-2 shrink-0"></v-icon>
<span class="truncate">{{ $t("app.tutorial_title") }}</span>
</button>
<button
type="button"
class="secondary-chip flex-1 min-[480px]:flex-none min-h-[44px] sm:min-h-0 justify-center"
@click="showChangelog"
>
<v-icon icon="mdi-history" size="20" class="mr-2"></v-icon>
{{ $t("app.changelog_title") }}
<button type="button" class="about-action-btn secondary-chip" @click="showChangelog">
<v-icon icon="mdi-history" size="20" class="mr-2 shrink-0"></v-icon>
<span class="truncate">{{ $t("app.changelog_title") }}</span>
</button>
<router-link
:to="{ name: 'licenses' }"
class="secondary-chip flex-1 min-[480px]:flex-none min-h-[44px] sm:min-h-0 justify-center inline-flex items-center no-underline"
class="about-action-btn secondary-chip inline-flex items-center no-underline"
>
<v-icon icon="mdi-license" size="20" class="mr-2"></v-icon>
{{ $t("about.third_party_licenses") }}
<v-icon icon="mdi-license" size="20" class="mr-2 shrink-0"></v-icon>
<span class="truncate">{{ $t("about.third_party_licenses") }}</span>
</router-link>
<button
v-if="isElectron"
type="button"
class="primary-chip flex-1 min-[480px]:flex-none min-h-[44px] sm:min-h-0 justify-center"
class="about-action-btn primary-chip"
@click="relaunch"
>
<v-icon icon="mdi-restart" size="20" class="mr-2"></v-icon>
{{ $t("common.restart_app") }}
<v-icon icon="mdi-restart" size="20" class="mr-2 shrink-0"></v-icon>
<span class="truncate">{{ $t("common.restart_app") }}</span>
</button>
<button
type="button"
class="secondary-chip flex-1 min-[480px]:flex-none min-h-[44px] sm:min-h-0 justify-center"
class="about-action-btn secondary-chip"
:disabled="reloadingRns"
@click="restartRns"
>
<v-icon icon="mdi-restart-alert" size="20" class="mr-2"></v-icon>
{{ reloadingRns ? $t("app.reloading_rns") : "Restart RNS" }}
<v-icon icon="mdi-restart-alert" size="20" class="mr-2 shrink-0"></v-icon>
<span class="truncate">{{
reloadingRns ? $t("app.reloading_rns") : "Restart RNS"
}}</span>
</button>
<button
type="button"
class="danger-chip flex-1 min-[480px]:flex-none min-h-[44px] sm:min-h-0 justify-center"
@click="shutdown"
>
<v-icon icon="mdi-power" size="20" class="mr-2"></v-icon>
{{ $t("common.shutdown", "Shutdown") }}
<button type="button" class="about-action-btn danger-chip" @click="shutdown">
<v-icon icon="mdi-power" size="20" class="mr-2 shrink-0"></v-icon>
<span class="truncate">{{ $t("common.shutdown", "Shutdown") }}</span>
</button>
</div>
</div>
@@ -1371,6 +1361,12 @@ export default {
if (this.isElectron) {
ElectronUtils.shutdown();
} else if (typeof window !== "undefined" && window.MeshChatXAndroid?.exitApp) {
try {
window.MeshChatXAndroid.exitApp();
} catch {
ToastUtils.success(this.$t("about.shutdown_sent"));
}
} else {
ToastUtils.success(this.$t("about.shutdown_sent"));
}
@@ -1450,4 +1446,7 @@ export default {
outline: 2px solid rgba(59, 130, 246, 0.35);
outline-offset: 2px;
}
.about-action-btn {
@apply min-w-0 min-h-[40px] justify-center whitespace-nowrap;
}
</style>

View File

@@ -5,7 +5,7 @@
<div class="w-full h-full overflow-y-auto">
<div class="mx-auto w-full max-w-4xl p-4 md:p-6 flex-1 flex flex-col min-h-full">
<!-- Tabs -->
<div class="flex flex-wrap justify-center border-b border-gray-200 dark:border-zinc-800 mb-6 shrink-0">
<div class="flex flex-wrap justify-center border-b border-gray-200 dark:border-zinc-800 shrink-0">
<button
:class="[
activeTab === 'phone'
@@ -86,7 +86,7 @@
class="flex-1 flex flex-col items-center justify-center py-12 px-4"
>
<div
class="glass-card w-full max-w-md !p-8 flex flex-col items-center text-center relative overflow-hidden"
class="w-full max-w-md border-b border-gray-200 dark:border-zinc-800 !p-8 flex flex-col items-center text-center relative overflow-hidden"
>
<!-- Status pulse background -->
<div
@@ -412,7 +412,7 @@
</div>
<div v-else class="space-y-6 my-6 max-w-3xl mx-auto w-full">
<div class="glass-card">
<div class="w-full border-b border-gray-200 dark:border-zinc-800 py-2">
<div class="flex items-center gap-3 mb-6">
<div class="bg-blue-100 dark:bg-blue-900/30 p-2.5 rounded-2xl">
<MaterialDesignIcon
@@ -504,8 +504,10 @@
</div>
</div>
<div class="pt-2 flex items-start justify-between gap-4">
<div class="flex flex-col gap-2 flex-1">
<div
class="pt-2 flex flex-col items-stretch gap-4 lg:flex-row lg:items-start lg:justify-between"
>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<Toggle
id="dnd-toggle"
:model-value="config?.do_not_disturb_enabled"
@@ -531,7 +533,7 @@
</div>
</div>
</div>
<div class="flex flex-col gap-2 shrink-0">
<div class="flex w-full shrink-0 flex-col gap-2 lg:w-auto">
<!-- <Toggle
id="call-recording-toggle"
:model-value="config?.call_recording_enabled"
@@ -547,7 +549,7 @@
<select
v-if="config"
v-model="config.telephone_audio_profile_id"
class="input-field !py-1 !px-2 !text-xs !rounded-lg !border-gray-200 dark:!border-zinc-800 min-w-[120px]"
class="input-field min-w-0 !rounded-lg !border-gray-200 !py-1 !px-2 !text-xs dark:!border-zinc-800 lg:min-w-[120px]"
@change="
updateConfig({
telephone_audio_profile_id: config.telephone_audio_profile_id,
@@ -634,9 +636,9 @@
v-if="callHistory.length > 0 && !activeCall && !isCallEnded"
class="space-y-4 max-w-3xl mx-auto w-full"
>
<div class="glass-card !p-0 overflow-hidden">
<div class="w-full border-b border-gray-200 dark:border-zinc-800 !p-0 overflow-hidden">
<div
class="px-5 py-4 border-b border-gray-100 dark:border-zinc-800 flex flex-col gap-4 bg-gray-50/50 dark:bg-zinc-800/20"
class="px-5 py-4 border-b border-gray-100 dark:border-zinc-800 flex flex-col gap-4 bg-transparent"
>
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
@@ -853,9 +855,7 @@
</div>
<div v-else class="space-y-4">
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div class="border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
<ul class="divide-y divide-gray-100 dark:divide-zinc-800">
<li
v-for="announce in discoveryAnnounces"
@@ -963,10 +963,7 @@
</div>
<!-- Voicemail Settings Card -->
<div
v-if="config"
class="mb-4 bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div v-if="config" class="mb-4 border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
<button
type="button"
class="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
@@ -1262,9 +1259,7 @@
</div>
<div v-else class="space-y-4">
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div class="border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
<div
class="px-4 py-3 border-b border-gray-200 dark:border-zinc-800 flex justify-between items-center"
>
@@ -1419,9 +1414,7 @@
</div>
<div v-else class="space-y-4">
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div class="border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
<ul class="divide-y divide-gray-100 dark:divide-zinc-800">
<li
v-for="contact in contacts"
@@ -1525,9 +1518,7 @@
<!-- Ringtone Tab -->
<div v-if="activeTab === 'ringtone' && config" class="flex-1 space-y-6 max-w-3xl mx-auto w-full">
<div
class="bg-white dark:bg-zinc-900 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-zinc-800"
>
<div class="w-full border-b border-gray-200 dark:border-zinc-800 py-6">
<template v-if="isRingtoneEditorOpen">
<RingtoneEditor
:ringtone="editingRingtoneForAudio"
@@ -1860,9 +1851,7 @@
</div>
<div class="flex-1 overflow-y-auto min-h-0">
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div class="border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
<div v-if="recordings.length === 0" class="py-12 text-center">
<MaterialDesignIcon
icon-name="microphone-off"

View File

@@ -3,7 +3,7 @@
<template>
<div class="flex flex-1 min-w-0 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
<div class="flex-1 overflow-y-auto p-4 md:p-6">
<div class="max-w-5xl mx-auto space-y-4">
<div class="max-w-5xl mx-auto space-y-0 border-b border-gray-200 dark:border-zinc-800 pb-6">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100">{{ $t("contacts.title") }}</h1>
@@ -11,50 +11,61 @@
{{ $t("contacts.description") }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="secondary-chip" @click="openMyIdentityDialog">
<MaterialDesignIcon icon-name="qrcode" class="size-4" />
{{ $t("contacts.share_my_identity") }}
</button>
<button
type="button"
class="secondary-chip"
class="secondary-chip justify-center px-3 sm:px-4"
:disabled="totalContactsCount === 0"
:title="$t('contacts.export_contacts')"
@click="exportContacts"
>
<MaterialDesignIcon icon-name="file-export" class="size-4" />
{{ $t("contacts.export_contacts") }}
<span class="hidden sm:inline">{{ $t("contacts.export_contacts") }}</span>
</button>
<button type="button" class="secondary-chip" @click="openImportDialog">
<button
type="button"
class="secondary-chip justify-center px-3 sm:px-4"
:title="$t('contacts.import_contacts')"
@click="openImportDialog"
>
<MaterialDesignIcon icon-name="file-import" class="size-4" />
{{ $t("contacts.import_contacts") }}
<span class="hidden sm:inline">{{ $t("contacts.import_contacts") }}</span>
</button>
<button type="button" class="primary-chip" @click="openAddDialog">
<button type="button" class="primary-chip hidden sm:inline-flex" @click="openAddDialog">
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("contacts.add_contact") }}
</button>
</div>
</div>
</div>
<div class="glass-card space-y-3">
<div class="flex items-center gap-2">
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
<div class="max-w-5xl mx-auto space-y-0 pt-4">
<div class="border-b border-gray-200 dark:border-zinc-800 pb-3">
<div class="relative group">
<MaterialDesignIcon
icon-name="magnify"
class="absolute left-3 top-1/2 -translate-y-1/2 size-5 shrink-0 text-gray-400 group-focus-within:text-blue-500 transition-colors pointer-events-none z-10"
/>
<input
v-model="contactsSearch"
type="text"
:placeholder="$t('contacts.search_placeholder')"
class="input-field"
class="input-field !pl-11"
@input="onContactsSearchInput"
/>
</div>
</div>
<div class="glass-card">
<div class="min-w-0">
<template v-if="isLoading && contacts.length === 0">
<div
v-for="i in 8"
:key="'skeleton-' + i"
class="rounded-2xl border border-gray-100 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/50 px-4 py-3 flex items-center gap-3"
class="flex items-center gap-3 border-b border-gray-100 px-1 py-3 dark:border-zinc-800"
>
<div
class="size-10 sm:size-12 rounded-full bg-gray-200 dark:bg-zinc-700 animate-pulse shrink-0"
@@ -71,11 +82,11 @@
>
{{ $t("contacts.no_contacts") }}
</div>
<div v-else class="space-y-2">
<div v-else class="divide-y divide-gray-100 dark:divide-zinc-800">
<div
v-for="contact in contacts"
:key="contact.id"
class="group rounded-2xl border border-gray-100 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/50 px-4 py-3 flex items-center gap-3 hover:border-blue-300 dark:hover:border-blue-700 transition-colors cursor-default"
class="group flex cursor-default items-center gap-3 px-1 py-3 transition-colors hover:bg-gray-50/80 dark:hover:bg-zinc-900/70"
@contextmenu.prevent="openContextMenu($event, contact)"
>
<div class="flex-shrink-0">
@@ -141,6 +152,15 @@
</div>
</div>
<button
type="button"
class="sm:hidden fixed bottom-5 right-4 z-[180] flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg ring-1 ring-blue-400/30 transition active:scale-95"
:title="$t('contacts.add_contact')"
@click="openAddDialog"
>
<MaterialDesignIcon icon-name="plus" class="size-7" />
</button>
<!-- Contact context menu -->
<ContextMenuPanel :show="contextMenu.visible" :x="contextMenu.x" :y="contextMenu.y" panel-class="z-[210]">
<ContextMenuItem @click="openConversation(contextMenu.contact)">
@@ -204,13 +224,25 @@
<label class="block text-xs uppercase tracking-wider font-semibold text-gray-500 mb-1">
{{ $t("contacts.hash_or_uri") }}
</label>
<input
v-model="newContactInput"
type="text"
class="input-field font-mono"
:placeholder="$t('contacts.hash_or_uri_placeholder')"
@keydown.enter.prevent="submitAddContact"
/>
<div class="relative">
<input
v-model="newContactInput"
type="text"
class="input-field font-mono"
:class="cameraSupported ? '!pr-12' : ''"
:placeholder="$t('contacts.hash_or_uri_placeholder')"
@keydown.enter.prevent="submitAddContact"
/>
<button
v-if="cameraSupported"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/40 transition"
:title="$t('contacts.scan_qr')"
@click="openScannerDialog"
>
<MaterialDesignIcon icon-name="qrcode-scan" class="size-5" />
</button>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="secondary-chip" @click="pasteFromClipboard">
@@ -823,6 +855,11 @@ export default {
video: { facingMode: "environment" },
audio: false,
});
if (!stream.getVideoTracks().length) {
this.scannerError = this.$t("contacts.camera_not_found");
stream.getTracks().forEach((track) => track.stop());
return;
}
this.scannerStream = stream;
const video = this.$refs.scannerVideo;
if (!video) return;
@@ -830,7 +867,7 @@ export default {
await video.play();
this.detectQrLoop();
} catch (e) {
this.scannerError = e.message || this.$t("contacts.camera_failed");
this.scannerError = this.describeCameraError(e);
}
},
detectQrLoop() {
@@ -871,6 +908,16 @@ export default {
this.isScannerDialogOpen = false;
this.stopScanner();
},
describeCameraError(error) {
const name = error?.name || "";
if (name === "NotAllowedError" || name === "SecurityError") {
return this.$t("contacts.camera_permission_denied");
}
if (name === "NotFoundError" || name === "DevicesNotFoundError") {
return this.$t("contacts.camera_not_found");
}
return this.$t("contacts.camera_failed");
},
},
};
</script>

View File

@@ -3,7 +3,7 @@
<template>
<label
:for="id"
class="relative inline-flex items-center"
class="relative inline-flex w-auto shrink-0 items-center gap-3"
:class="disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'"
>
<input
@@ -15,9 +15,11 @@
@change="!disabled && $emit('update:modelValue', $event.target.checked)"
/>
<div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-zinc-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"
class="relative h-6 w-11 shrink-0 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-zinc-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"
></div>
<span v-if="label" class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{ label }}</span>
<span v-if="label" class="min-w-0 text-sm font-medium leading-snug text-gray-900 dark:text-gray-300">{{
label
}}</span>
</label>
</template>
@@ -27,7 +29,7 @@ export default {
props: {
id: {
type: String,
required: true,
default: null,
},
modelValue: {
type: Boolean,

View File

@@ -905,17 +905,7 @@
<button
type="button"
class="primary-chip !py-1.5 !px-2 !text-[10px] shrink-0"
@click="
newInterfaceName = communityIface.name;
newInterfaceType = communityIface.type;
newInterfaceTargetHost = communityIface.target_host;
newInterfaceTargetPort = communityIface.target_port;
newInterfaceTransportIdentity = communityIface.transport_identity || null;
I2PSettings.newInterfacePeers =
communityIface.type === 'I2PInterface' && communityIface.i2p_peers
? [...communityIface.i2p_peers]
: [];
"
@click="quickAddInterfaceFromConfig(communityIface)"
>
{{ $t("interfaces.community_use_preset") }}
</button>
@@ -1012,7 +1002,7 @@
:key="cfg.name"
type="button"
class="bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 rounded-lg px-2 py-1 text-[9px] font-bold text-emerald-600 dark:text-emerald-400 transition"
@click="applyConfig(cfg)"
@click="quickAddInterfaceFromConfig(cfg)"
>
{{ $t("interfaces.quick_import_apply", { name: cfg.name }) }}
</button>
@@ -1253,6 +1243,11 @@ export default {
this.isEditingInterface = true;
this.loadInterfaceToEdit(interfaceName);
}
// check if we have a discovered interface prefill payload
if (this.$route.query.from_discovered) {
this.applyDiscoveredInterfacePrefill();
}
},
methods: {
async getConfig() {
@@ -1451,9 +1446,9 @@ export default {
this.detectedConfigs = configs;
// if only one config, auto-apply it
// if only one config, auto-import it directly
if (configs.length === 1) {
this.applyConfig(configs[0]);
this.quickAddInterfaceFromConfig(configs[0]);
}
},
applyConfig(config) {
@@ -1523,6 +1518,128 @@ export default {
this.rawConfigInput = "";
this.detectedConfigs = [];
},
buildPayloadFromImportedConfig(config) {
const discoveryEnabled =
config.discoverable !== undefined && config.discoverable !== null && config.discoverable !== ""
? this.parseBool(config.discoverable)
: false;
const i2pPeers =
config.type === "I2PInterface"
? Array.isArray(config.i2p_peers)
? config.i2p_peers.map((p) => String(p).trim()).filter(Boolean)
: Array.isArray(config.peers)
? config.peers.map((p) => String(p).trim()).filter(Boolean)
: []
: undefined;
return {
allow_overwriting_interface: false,
name: config.name,
type: config.type,
target_host: config.target_host || config.remote || null,
target_port: this.numOrNull(config.target_port),
transport_identity: config.transport_identity || null,
peers: i2pPeers,
listen_ip: config.listen_ip || null,
listen_port: this.numOrNull(config.listen_port),
port: config.port || null,
frequency: this.numOrNull(config.frequency),
bandwidth: this.numOrNull(config.bandwidth),
txpower: this.numOrNull(config.txpower),
spreadingfactor: this.numOrNull(config.spreadingfactor),
codingrate: this.numOrNull(config.codingrate),
command: config.command || null,
respawn_delay: this.numOrNull(config.respawn_delay),
discoverable: discoveryEnabled ? "yes" : null,
discovery_name: discoveryEnabled ? config.discovery_name || config.name || null : null,
announce_interval: discoveryEnabled ? (this.numOrNull(config.announce_interval) ?? 360) : null,
reachable_on: discoveryEnabled ? config.reachable_on || config.target_host || null : null,
discovery_stamp_value: discoveryEnabled ? (this.numOrNull(config.discovery_stamp_value) ?? 14) : null,
discovery_encrypt: discoveryEnabled
? config.discovery_encrypt !== undefined
? this.parseBool(config.discovery_encrypt)
: false
: null,
publish_ifac: discoveryEnabled
? config.publish_ifac !== undefined
? this.parseBool(config.publish_ifac)
: false
: null,
latitude: discoveryEnabled ? this.numOrNull(config.latitude) : null,
longitude: discoveryEnabled ? this.numOrNull(config.longitude) : null,
height: discoveryEnabled ? this.numOrNull(config.height) : null,
discovery_frequency: discoveryEnabled ? this.numOrNull(config.discovery_frequency) : null,
discovery_bandwidth: discoveryEnabled ? this.numOrNull(config.discovery_bandwidth) : null,
discovery_modulation: discoveryEnabled ? this.numOrNull(config.discovery_modulation) : null,
mode: config.mode || "full",
bitrate: this.numOrNull(config.bitrate),
network_name: config.network_name || null,
passphrase: config.passphrase || null,
};
},
applyDiscoveredInterfacePrefill() {
let prefill = null;
try {
if (typeof sessionStorage !== "undefined") {
const raw = sessionStorage.getItem("meshchatx.discoveredInterfacePrefill");
if (raw) {
prefill = JSON.parse(raw);
sessionStorage.removeItem("meshchatx.discoveredInterfacePrefill");
}
}
} catch (e) {
console.log(e);
}
if (!prefill) return;
if (prefill.config_entry) {
this.rawConfigInput = prefill.config_entry;
this.handleRawConfigInput();
return;
}
const config = {
name: prefill.name || "Discovered Interface",
type: prefill.type || "BackboneInterface",
target_host: prefill.target_host || null,
target_port: prefill.target_port != null ? String(prefill.target_port) : null,
transport_identity: prefill.transport_identity || null,
network_name: prefill.network_name || null,
passphrase: prefill.passphrase || null,
frequency: prefill.frequency ?? null,
bandwidth: prefill.bandwidth ?? null,
spreadingfactor: prefill.spreadingfactor ?? null,
codingrate: prefill.codingrate ?? null,
latitude: prefill.latitude ?? null,
longitude: prefill.longitude ?? null,
height: prefill.height ?? null,
};
this.applyConfig(config);
ToastUtils.success(this.$t("interfaces.discovered_prefill_applied"));
},
async quickAddInterfaceFromConfig(config) {
if (!config || !config.type || !config.name || this.isSaving) {
return;
}
this.isSaving = true;
try {
const response = await window.api.post(
`/api/v1/reticulum/interfaces/add`,
this.buildPayloadFromImportedConfig(config)
);
ToastUtils.success(response.data?.message || `Imported interface "${config.name}"`);
GlobalState.hasPendingInterfaceChanges = true;
GlobalState.modifiedInterfaceNames.add(config.name);
this.rawConfigInput = "";
this.detectedConfigs = [];
this.$router.push({ name: "interfaces" });
} catch (e) {
const message = e.response?.data?.message ?? `Failed to import "${config.name}"`;
ToastUtils.error(message);
console.log(e);
} finally {
this.isSaving = false;
}
},
async saveInterface() {
if (this.isSaving) return;
this.isSaving = true;

View File

@@ -20,7 +20,7 @@
<button
v-if="isElectron"
type="button"
class="ml-auto inline-flex items-center gap-2 rounded-full border border-white/40 px-4 py-1.5 text-sm font-semibold text-white hover:bg-white/10 transition"
class="ml-auto inline-flex items-center gap-2 rounded-full bg-white px-4 py-1.5 text-sm font-bold text-amber-600 hover:bg-white/90 transition shadow-sm"
@click="relaunch"
>
<MaterialDesignIcon icon-name="restart" class="w-4 h-4" />
@@ -44,7 +44,7 @@
<div class="flex flex-wrap gap-2 pt-2">
<RouterLink
:to="{ name: 'interfaces.add' }"
class="primary-chip px-4 py-2 text-sm min-h-[44px] sm:min-h-0 items-center justify-center inline-flex"
class="primary-chip px-4 py-2 text-sm min-h-[44px] sm:min-h-0 items-center justify-center hidden sm:inline-flex"
>
<MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
{{ $t("interfaces.add_interface") }}
@@ -59,7 +59,12 @@
</button>
<button
type="button"
class="secondary-chip text-sm"
class="secondary-chip text-sm transition-shadow"
:class="
showRestartReminder
? 'ring-2 ring-amber-400 shadow-lg shadow-amber-500/40 animate-pulse motion-reduce:animate-none'
: ''
"
:disabled="reloadingRns"
@click="reloadRns"
>
@@ -259,7 +264,9 @@
v-for="iface in sortedDiscoveredInterfaces"
:key="iface.discovery_hash || iface.name"
class="interface-card group transition-all duration-300 min-w-0"
:class="{ 'opacity-70 grayscale-[0.3]': !isDiscoveredConnected(iface) }"
:class="{
'opacity-85 md:opacity-70 md:grayscale-[0.3]': !isDiscoveredConnected(iface),
}"
>
<div
class="flex flex-col gap-3 sm:flex-row sm:gap-4 sm:items-start relative min-w-0"
@@ -267,7 +274,7 @@
<!-- Disconnected Overlay -->
<div
v-if="!isDiscoveredConnected(iface)"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/20 dark:bg-zinc-900/20 backdrop-blur-[0.5px] rounded-3xl pointer-events-none"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/25 dark:bg-zinc-900/25 md:backdrop-blur-[0.5px] rounded-3xl pointer-events-none"
>
<div
class="bg-red-500/90 text-white px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider"
@@ -373,6 +380,50 @@
>
</div>
<div
v-if="discoveredNetworkName(iface)"
class="flex items-center gap-2 text-amber-700 dark:text-amber-300 hover:text-amber-500 cursor-pointer transition-colors min-w-0"
:title="$t('interfaces.discovered_copy_network_name')"
data-testid="discovered-network-name"
@click="
copyToClipboard(
discoveredNetworkName(iface),
$t('interfaces.discovered_network_name')
)
"
>
<MaterialDesignIcon
icon-name="shield-key"
class="w-3.5 h-3.5 shrink-0"
/>
<span class="truncate font-mono"
>{{ $t("interfaces.discovered_network_name") }}:
{{ discoveredNetworkName(iface) }}</span
>
</div>
<div
v-if="discoveredPassphrase(iface)"
class="flex items-center gap-2 text-amber-700 dark:text-amber-300 hover:text-amber-500 cursor-pointer transition-colors min-w-0"
:title="$t('interfaces.discovered_copy_passphrase')"
data-testid="discovered-passphrase"
@click="
copyToClipboard(
discoveredPassphrase(iface),
$t('interfaces.discovered_passphrase')
)
"
>
<MaterialDesignIcon
icon-name="shield-lock"
class="w-3.5 h-3.5 shrink-0"
/>
<span class="truncate font-mono"
>{{ $t("interfaces.discovered_passphrase") }}:
{{ maskPassphrase(discoveredPassphrase(iface)) }}</span
>
</div>
<div
v-if="iface.latitude != null && iface.longitude != null"
class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
@@ -424,6 +475,23 @@
v-if="openDiscoveryActionKey === discoveryKey(iface)"
class="absolute right-0 mt-1 z-20 min-w-44 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-lg p-1"
>
<button
type="button"
class="w-full text-left px-3 py-2 text-xs rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-700 dark:text-blue-300"
data-testid="use-discovered-interface"
@click="useDiscoveredInterface(iface)"
>
{{ $t("interfaces.discovered_use_this") }}
</button>
<button
v-if="iface.config_entry"
type="button"
class="w-full text-left px-3 py-2 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-200"
data-testid="copy-discovered-config"
@click="copyDiscoveredConfigEntry(iface)"
>
{{ $t("interfaces.discovered_copy_config") }}
</button>
<button
type="button"
class="w-full text-left px-3 py-2 text-xs rounded-lg hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300"
@@ -601,6 +669,14 @@
</div>
</div>
<RouterLink
:to="{ name: 'interfaces.add' }"
class="sm:hidden fixed bottom-5 right-4 z-[60] flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg ring-1 ring-blue-400/30 transition active:scale-95"
:title="$t('interfaces.add_interface')"
>
<MaterialDesignIcon icon-name="plus" class="w-7 h-7" />
</RouterLink>
<ImportInterfacesModal ref="import-interfaces-modal" @dismissed="onImportInterfacesModalDismissed" />
</template>
@@ -1266,6 +1342,62 @@ export default {
navigator.clipboard.writeText(text);
ToastUtils.success(`${label} copied to clipboard`);
},
discoveredNetworkName(iface) {
if (!iface) return null;
return iface.network_name || iface.ifac_netname || null;
},
discoveredPassphrase(iface) {
if (!iface) return null;
return iface.passphrase || iface.ifac_netkey || null;
},
maskPassphrase(value) {
if (!value) return "";
const str = String(value);
if (str.length <= 4) return "*".repeat(str.length);
return `${str.slice(0, 2)}${"*".repeat(Math.max(4, str.length - 4))}${str.slice(-2)}`;
},
copyDiscoveredConfigEntry(iface) {
this.openDiscoveryActionKey = null;
const entry = iface?.config_entry;
if (!entry) {
ToastUtils.error(this.$t("interfaces.discovered_no_config"));
return;
}
this.copyToClipboard(entry, this.$t("interfaces.discovered_config_block"));
},
useDiscoveredInterface(iface) {
this.openDiscoveryActionKey = null;
if (!iface) return;
const prefill = {
name: iface.name || "",
type: iface.type || null,
target_host: iface.reachable_on || iface.target_host || iface.remote || null,
target_port: iface.port || iface.target_port || null,
transport_identity: iface.transport_id || iface.transport_identity || null,
network_name: this.discoveredNetworkName(iface),
passphrase: this.discoveredPassphrase(iface),
discoverable: iface.discoverable || null,
config_entry: iface.config_entry || null,
frequency: iface.frequency ?? null,
bandwidth: iface.bandwidth ?? null,
spreadingfactor: iface.sf ?? iface.spreadingfactor ?? null,
codingrate: iface.cr ?? iface.codingrate ?? null,
latitude: iface.latitude ?? null,
longitude: iface.longitude ?? null,
height: iface.height ?? null,
};
try {
if (typeof sessionStorage !== "undefined") {
sessionStorage.setItem("meshchatx.discoveredInterfacePrefill", JSON.stringify(prefill));
}
} catch (e) {
console.log(e);
}
this.$router.push({
name: "interfaces.add",
query: { from_discovered: "1" },
});
},
setStatusFilter(value) {
this.statusFilter = value;
},

View File

@@ -6,7 +6,7 @@
<div
class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-0 px-3 py-2 sm:px-4 border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur z-10 relative"
>
<div class="flex items-center min-w-0 gap-2">
<div class="hidden sm:flex items-center min-w-0 gap-2">
<v-icon icon="mdi-map" class="text-blue-500 dark:text-blue-400 shrink-0" size="24"></v-icon>
<h1 class="text-lg sm:text-xl font-black text-gray-900 dark:text-white truncate">
{{ $t("map.title") }}
@@ -65,12 +65,22 @@
<button
v-if="!isPopoutMode"
type="button"
class="flex p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors shrink-0"
class="hidden sm:flex p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors shrink-0"
:title="$t('map.pop_out')"
@click="openMapPopout"
>
<MaterialDesignIcon icon-name="open-in-new" class="size-[18px] sm:size-5" />
</button>
<!-- search toggle (mobile only) -->
<button
v-if="!offlineEnabled"
type="button"
class="sm:hidden p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors shrink-0"
:title="$t('map.search_placeholder')"
@click="toggleMobileSearch"
>
<MaterialDesignIcon :icon-name="isMobileSearchOpen ? 'close' : 'magnify'" class="size-[18px]" />
</button>
<!-- settings button -->
<button
type="button"
@@ -84,9 +94,9 @@
<!-- map container -->
<div class="relative flex-1 min-h-0">
<!-- drawing toolbar -->
<!-- drawing toolbar (mobile: top center with small gap; desktop unchanged) -->
<div
class="absolute top-14 left-1/2 -translate-x-1/2 sm:top-2 z-20 flex flex-col gap-2 transform-gpu w-max max-w-[98vw]"
class="absolute top-2 left-1/2 -translate-x-1/2 z-20 flex flex-col gap-2 transform-gpu w-max max-w-[98vw] sm:top-2"
>
<div
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-row p-0.5 sm:p-1 gap-0 sm:gap-0.5 border-0"
@@ -168,11 +178,12 @@
</div>
</div>
<!-- search bar -->
<!-- search bar (mobile: below drawing toolbar when open; desktop: top-right) -->
<div
v-if="!offlineEnabled"
v-show="!isMobileScreen || isMobileSearchOpen"
ref="searchContainer"
class="absolute top-2 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 md:max-lg:w-72 lg:w-80 z-30"
class="absolute left-4 right-4 top-[calc(0.5rem+2.75rem+0.5rem)] z-30 sm:top-2 sm:left-auto sm:right-4 sm:w-80 md:max-lg:w-72 lg:w-80"
>
<div class="relative">
<div class="flex items-center bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border-0 ring-0">
@@ -1100,15 +1111,6 @@
</defs>
</svg>
</div>
<!-- floating upload button (mobile only) -->
<button
class="sm:hidden fixed bottom-4 right-4 z-30 p-4 bg-blue-500 hover:bg-blue-600 text-white rounded-full shadow-lg transition-colors"
:title="$t('map.upload_mbtiles')"
@click="$refs.fileInput.click()"
>
<MaterialDesignIcon icon-name="upload" class="size-6" />
</button>
</div>
<!-- save drawing modal -->
@@ -1391,6 +1393,7 @@ export default {
arrowSvgWidth: 200,
arrowSvgHeight: 200,
isMobileScreen: false,
isMobileSearchOpen: false,
// MBTiles management
mbtilesList: [],
@@ -2657,6 +2660,22 @@ export default {
},
checkScreenSize() {
this.isMobileScreen = window.innerWidth < 640;
if (!this.isMobileScreen) {
this.isMobileSearchOpen = false;
}
},
toggleMobileSearch() {
this.isMobileSearchOpen = !this.isMobileSearchOpen;
if (this.isMobileSearchOpen) {
this.$nextTick(() => {
const input = this.$refs.searchContainer?.querySelector("input");
if (input) {
input.focus();
}
});
} else {
this.isSearchFocused = false;
}
},
async fetchPeers() {
if (!window.api) return;
@@ -3806,4 +3825,14 @@ export default {
.fade-leave-to {
opacity: 0;
}
@media (max-width: 639px) {
:deep(.ol-zoom) {
left: auto;
right: 0.75rem;
top: auto;
bottom: 0.75rem;
z-index: 12;
}
}
</style>

View File

@@ -17,11 +17,10 @@
<button
v-else
type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition"
class="my-auto inline-flex items-center justify-center rounded-lg p-1.5 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-gray-800 dark:hover:text-zinc-100 transition-colors"
@click="showMenu"
>
<MaterialDesignIcon icon-name="microphone-plus" class="w-4 h-4" />
<span class="hidden xl:inline-block whitespace-nowrap">Add Voice</span>
<MaterialDesignIcon icon-name="microphone-plus" class="w-5 h-5" />
</button>
<div class="relative block">

View File

@@ -12,10 +12,6 @@
<MaterialDesignIcon icon-name="refresh" class="size-5 text-red-500" />
<span>{{ $t("messages.retry_failed") }}</span>
</DropDownMenuItem>
<DropDownMenuItem @click="$emit('open-telemetry-history')">
<MaterialDesignIcon icon-name="satellite-variant" class="size-5" />
<span>{{ $t("messages.telemetry_history") }}</span>
</DropDownMenuItem>
<DropDownMenuItem @click="$emit('start-call')">
<MaterialDesignIcon icon-name="phone" class="size-5" />
<span>{{ $t("messages.start_call") }}</span>
@@ -24,20 +20,31 @@
<MaterialDesignIcon icon-name="notebook-outline" class="size-5" />
<span>{{ $t("messages.share_contact") }}</span>
</DropDownMenuItem>
<div class="border-t border-gray-100 dark:border-zinc-800" />
<!-- popout button -->
<DropDownMenuItem @click="$emit('popout')">
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
<span>{{ $t("messages.pop_out_chat") }}</span>
</DropDownMenuItem>
<!-- ping button -->
<DropDownMenuItem @click="onPingDestination">
<MaterialDesignIcon icon-name="flash" class="size-5" />
<span>Ping Destination</span>
</DropDownMenuItem>
<DropDownMenuItem @click="$emit('open-telemetry-history')">
<MaterialDesignIcon icon-name="satellite-variant" class="size-5" />
<span>{{ $t("messages.telemetry_history") }}</span>
</DropDownMenuItem>
<div v-if="GlobalState?.config?.telemetry_enabled" class="border-t">
<DropDownMenuItem @click="onToggleTelemetryTrust">
<MaterialDesignIcon
:icon-name="contact?.is_telemetry_trusted ? 'shield-check' : 'shield-outline'"
:class="contact?.is_telemetry_trusted ? 'text-blue-500' : 'text-gray-500'"
class="size-5"
/>
<span>{{
contact?.is_telemetry_trusted
? $t("app.telemetry_trust_revoke")
: $t("app.telemetry_trust_grant")
}}</span>
</DropDownMenuItem>
</div>
<div class="border-t border-gray-100 dark:border-zinc-800" />
<!-- set custom display name button -->
<DropDownMenuItem @click="onSetCustomDisplayName">
@@ -45,6 +52,12 @@
<span>Set Custom Display Name</span>
</DropDownMenuItem>
<!-- popout button -->
<DropDownMenuItem @click="$emit('popout')">
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
<span>{{ $t("messages.pop_out_chat") }}</span>
</DropDownMenuItem>
<!-- block/unblock button -->
<div class="border-t">
<DropDownMenuItem v-if="!isBlocked" @click="onBlockDestination">
@@ -64,22 +77,6 @@
<span class="text-red-500">Delete Message History</span>
</DropDownMenuItem>
</div>
<!-- telemetry trust toggle -->
<div v-if="GlobalState?.config?.telemetry_enabled" class="border-t">
<DropDownMenuItem @click="onToggleTelemetryTrust">
<MaterialDesignIcon
:icon-name="contact?.is_telemetry_trusted ? 'shield-check' : 'shield-outline'"
:class="contact?.is_telemetry_trusted ? 'text-blue-500' : 'text-gray-500'"
class="size-5"
/>
<span>{{
contact?.is_telemetry_trusted
? $t("app.telemetry_trust_revoke")
: $t("app.telemetry_trust_grant")
}}</span>
</DropDownMenuItem>
</div>
</template>
</DropDownMenu>
<div v-else class="flex items-center gap-0.5 sm:gap-1 flex-wrap justify-end max-w-[min(100%,52vw)] sm:max-w-none">
@@ -91,32 +88,17 @@
>
<MaterialDesignIcon icon-name="refresh" class="size-5 text-red-500" />
</IconButton>
<IconButton :title="$t('messages.telemetry_history')" class="shrink-0" @click="$emit('open-telemetry-history')">
<MaterialDesignIcon icon-name="satellite-variant" class="size-5" />
</IconButton>
<IconButton :title="$t('messages.start_call')" class="shrink-0" @click="$emit('start-call')">
<MaterialDesignIcon icon-name="phone" class="size-5" />
</IconButton>
<IconButton :title="$t('messages.share_contact')" class="shrink-0" @click="$emit('share-contact')">
<MaterialDesignIcon icon-name="notebook-outline" class="size-5" />
</IconButton>
<IconButton :title="$t('messages.pop_out_chat')" class="shrink-0" @click="$emit('popout')">
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
</IconButton>
<IconButton title="Ping Destination" class="shrink-0" @click="onPingDestination">
<MaterialDesignIcon icon-name="flash" class="size-5" />
</IconButton>
<IconButton :title="$t('messages.custom_display_name')" class="shrink-0" @click="onSetCustomDisplayName">
<MaterialDesignIcon icon-name="account-edit" class="size-5" />
</IconButton>
<IconButton v-if="!isBlocked" title="Banish User" class="shrink-0" @click="onBlockDestination">
<MaterialDesignIcon icon-name="gavel" class="size-5 text-red-500" />
</IconButton>
<IconButton v-else title="Lift Banishment" class="shrink-0" @click="onUnblockDestination">
<MaterialDesignIcon icon-name="check-circle" class="size-5 text-green-500" />
</IconButton>
<IconButton title="Delete Message History" class="shrink-0" @click="onDeleteMessageHistory">
<MaterialDesignIcon icon-name="delete" class="size-5 text-red-500" />
<IconButton :title="$t('messages.telemetry_history')" class="shrink-0" @click="$emit('open-telemetry-history')">
<MaterialDesignIcon icon-name="satellite-variant" class="size-5" />
</IconButton>
<IconButton
v-if="GlobalState?.config?.telemetry_enabled"
@@ -130,6 +112,21 @@
class="size-5"
/>
</IconButton>
<IconButton :title="$t('messages.custom_display_name')" class="shrink-0" @click="onSetCustomDisplayName">
<MaterialDesignIcon icon-name="account-edit" class="size-5" />
</IconButton>
<IconButton :title="$t('messages.pop_out_chat')" class="shrink-0" @click="$emit('popout')">
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
</IconButton>
<IconButton v-if="!isBlocked" title="Banish User" class="shrink-0" @click="onBlockDestination">
<MaterialDesignIcon icon-name="gavel" class="size-5 text-red-500" />
</IconButton>
<IconButton v-else title="Lift Banishment" class="shrink-0" @click="onUnblockDestination">
<MaterialDesignIcon icon-name="check-circle" class="size-5 text-green-500" />
</IconButton>
<IconButton title="Delete Message History" class="shrink-0" @click="onDeleteMessageHistory">
<MaterialDesignIcon icon-name="delete" class="size-5 text-red-500" />
</IconButton>
</div>
</template>

View File

@@ -37,7 +37,14 @@
cv.openImage(cv.lxmfImageUrl(imgItem.lxmf_message.hash), cv.imageGroupGalleryUrls(entry.items))
"
>
<InViewAnimatedImg
v-if="isAnimatedRasterType(imgItem.lxmf_message.fields?.image?.image_type)"
:src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
fit-parent
img-class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
/>
<img
v-else
:src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
loading="lazy"
decoding="async"
@@ -60,7 +67,14 @@
cv.openImage(cv.lxmfImageUrl(imgItem.lxmf_message.hash), cv.imageGroupGalleryUrls(entry.items))
"
>
<InViewAnimatedImg
v-if="isAnimatedRasterType(imgItem.lxmf_message.fields?.image?.image_type)"
:src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
fit-parent
img-class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
/>
<img
v-else
:src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
loading="lazy"
decoding="async"
@@ -79,7 +93,18 @@
)
"
>
<InViewAnimatedImg
v-if="
isAnimatedRasterType(
cv.imageGroupSortedChron(entry.items)[2].lxmf_message.fields?.image?.image_type
)
"
:src="cv.lxmfImageUrl(cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash)"
fit-parent
img-class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
/>
<img
v-else
:src="cv.lxmfImageUrl(cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash)"
loading="lazy"
decoding="async"
@@ -99,7 +124,14 @@
cv.openImage(cv.lxmfImageUrl(cell.lxmf_message.hash), cv.imageGroupGalleryUrls(entry.items))
"
>
<InViewAnimatedImg
v-if="isAnimatedRasterType(cell.lxmf_message.fields?.image?.image_type)"
:src="cv.lxmfImageUrl(cell.lxmf_message.hash)"
fit-parent
img-class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
/>
<img
v-else
:src="cv.lxmfImageUrl(cell.lxmf_message.hash)"
loading="lazy"
decoding="async"
@@ -313,14 +345,32 @@
class="relative group w-full max-w-[min(280px,85vw)] rounded-2xl overflow-hidden ring-1 ring-black/10 dark:ring-white/10 shadow-md mb-1.5"
:class="chatItem.is_outbound ? 'ml-auto' : 'mr-auto'"
>
<img
:src="cv.pendingOutboundImageSrc(chatItem)"
loading="lazy"
decoding="async"
class="max-h-[min(320px,55vh)] w-full cursor-pointer object-cover object-center transition-transform hover:scale-[1.01]"
alt=""
@click.stop="cv.openImage(cv.pendingOutboundImageSrc(chatItem))"
/>
<template
v-if="['tgs', 'webm'].includes((chatItem.lxmf_message.fields.image.image_type || '').toLowerCase())"
>
<StickerView
:src="cv.pendingOutboundImageSrc(chatItem)"
:image-type="(chatItem.lxmf_message.fields.image.image_type || '').toLowerCase()"
class="max-h-[min(320px,55vh)] w-full bg-black/5 dark:bg-white/5"
/>
</template>
<template v-else>
<InViewAnimatedImg
v-if="isAnimatedRasterType(chatItem.lxmf_message.fields?.image?.image_type)"
:src="cv.pendingOutboundImageSrc(chatItem)"
img-class="max-h-[min(320px,55vh)] w-full cursor-pointer object-contain object-center bg-black/5 dark:bg-white/5 transition-transform hover:scale-[1.01]"
@click.stop="cv.openImage(cv.pendingOutboundImageSrc(chatItem))"
/>
<img
v-else
:src="cv.pendingOutboundImageSrc(chatItem)"
loading="lazy"
decoding="async"
class="max-h-[min(320px,55vh)] w-full cursor-pointer object-contain object-center bg-black/5 dark:bg-white/5 transition-transform hover:scale-[1.01]"
alt=""
@click.stop="cv.openImage(cv.pendingOutboundImageSrc(chatItem))"
/>
</template>
<div
class="pointer-events-none absolute bottom-2 left-2 rounded-lg bg-black/60 px-2.5 py-1 text-xs text-white opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 sm:opacity-100"
>
@@ -931,6 +981,9 @@
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import AudioWaveformPlayer from "./AudioWaveformPlayer.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import StickerView from "../stickers/StickerView.vue";
import InViewAnimatedImg from "./InViewAnimatedImg.vue";
import { isAnimatedRasterType } from "../../js/inViewObserver.js";
export default {
name: "ConversationMessageEntry",
@@ -938,6 +991,8 @@ export default {
MaterialDesignIcon,
AudioWaveformPlayer,
LxmfUserIcon,
StickerView,
InViewAnimatedImg,
},
props: {
entry: {
@@ -949,5 +1004,8 @@ export default {
required: true,
},
},
methods: {
isAnimatedRasterType,
},
};
</script>

View File

@@ -477,12 +477,12 @@
<img
v-if="newMessageImageUrls[0]"
:src="newMessageImageUrls[0]"
class="max-h-52 w-full object-cover object-center"
class="max-h-52 w-full object-contain object-center bg-black/5 dark:bg-white/5"
/>
</button>
<button
type="button"
class="absolute -top-1 -right-1 inline-flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-600 dark:text-gray-200 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/40 shadow-md"
class="absolute top-1.5 right-1.5 inline-flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-600 dark:text-gray-200 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/40 shadow-md"
@click.stop="removeImageAttachment(0)"
>
<MaterialDesignIcon icon-name="close" class="w-3.5 h-3.5" />
@@ -503,7 +503,7 @@
</button>
<button
type="button"
class="absolute top-1 right-1 inline-flex items-center justify-center w-6 h-6 rounded-full bg-black/55 text-white hover:bg-black/70 shadow-md"
class="absolute top-1.5 right-1.5 inline-flex items-center justify-center w-6 h-6 rounded-full bg-black/55 text-white hover:bg-black/70 shadow-md"
@click.stop="removeImageAttachment(index)"
>
<MaterialDesignIcon icon-name="close" class="w-3.5 h-3.5" />
@@ -525,7 +525,7 @@
</button>
<button
type="button"
class="absolute top-1 right-1 inline-flex items-center justify-center w-6 h-6 rounded-full bg-black/55 text-white hover:bg-black/70 shadow-md"
class="absolute top-1.5 right-1.5 inline-flex items-center justify-center w-6 h-6 rounded-full bg-black/55 text-white hover:bg-black/70 shadow-md"
@click.stop="removeImageAttachment(index)"
>
<MaterialDesignIcon icon-name="close" class="w-3.5 h-3.5" />
@@ -545,7 +545,7 @@
</button>
<button
type="button"
class="absolute top-1 right-1 inline-flex items-center justify-center w-6 h-6 rounded-full bg-black/55 text-white hover:bg-black/70 shadow-md"
class="absolute top-1.5 right-1.5 inline-flex items-center justify-center w-6 h-6 rounded-full bg-black/55 text-white hover:bg-black/70 shadow-md"
@click.stop="removeImageAttachment(2)"
>
<MaterialDesignIcon icon-name="close" class="w-3.5 h-3.5" />
@@ -577,7 +577,7 @@
</button>
<button
type="button"
class="absolute top-1 right-1 inline-flex items-center justify-center w-6 h-6 rounded-full bg-black/55 text-white hover:bg-black/70 shadow-md"
class="absolute top-1.5 right-1.5 inline-flex items-center justify-center w-6 h-6 rounded-full bg-black/55 text-white hover:bg-black/70 shadow-md"
@click.stop="removeImageAttachment(slot - 1)"
>
<MaterialDesignIcon icon-name="close" class="w-3.5 h-3.5" />
@@ -641,7 +641,7 @@
ref="message-input"
v-model="newMessageText"
:readonly="isTranslatingMessage"
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 block w-full min-w-0 pl-3 sm:pl-4 pr-11 py-2.5 resize-none shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500 min-h-[44px] max-h-[200px] overflow-y-auto leading-snug"
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 block w-full min-w-0 pl-3 sm:pl-4 pr-[76px] py-2.5 resize-none shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500 min-h-[44px] max-h-[200px] overflow-y-auto leading-snug"
rows="1"
spellcheck="true"
:placeholder="$t('messages.send_placeholder')"
@@ -649,20 +649,36 @@
@keydown.enter.shift.exact.prevent="onShiftEnterPressed"
@paste="onMessagePaste"
></textarea>
<button
type="button"
class="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-lg p-1.5 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-gray-800 dark:hover:text-zinc-100"
:title="$t('stickers.picker_tooltip')"
@click.stop="toggleStickerPicker"
>
<MaterialDesignIcon icon-name="emoticon-outline" class="w-5 h-5" />
</button>
<div class="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
<AddAudioButton
:is-recording-audio-attachment="isRecordingAudioAttachment"
@start-recording="startRecordingAudioAttachment($event)"
@stop-recording="stopRecordingAudioAttachment"
>
<span class="text-[10px] whitespace-nowrap">
{{
$t("messages.recording", {
duration: audioAttachmentRecordingDuration,
})
}}
</span>
</AddAudioButton>
<button
type="button"
class="inline-flex items-center justify-center rounded-lg p-1.5 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-gray-800 dark:hover:text-zinc-100 transition-colors"
:title="$t('stickers.picker_tooltip')"
@click.stop="toggleStickerPicker"
>
<MaterialDesignIcon icon-name="emoticon-outline" class="w-5 h-5" />
</button>
</div>
<div
v-if="isStickerPickerOpen"
class="absolute bottom-full right-0 mb-2 z-50 w-[min(320px,85vw)] max-h-[min(420px,70vh)] flex flex-col rounded-2xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xl overflow-hidden"
:class="{
'ring-2 ring-blue-500/50 ring-offset-2 ring-offset-white dark:ring-offset-zinc-900':
stickerDropActive && emojiStickerTab === 'stickers',
(stickerDropActive && emojiStickerTab === 'stickers') ||
(gifDropActive && emojiStickerTab === 'gifs'),
}"
@click.stop
>
@@ -698,6 +714,20 @@
>
{{ $t("stickers.tab_stickers") }}
</button>
<button
type="button"
role="tab"
:aria-selected="emojiStickerTab === 'gifs'"
class="flex-1 rounded-lg px-2 py-1.5 text-xs font-medium transition-colors"
:class="
emojiStickerTab === 'gifs'
? 'bg-blue-100 dark:bg-blue-950/60 text-blue-800 dark:text-blue-200'
: 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800'
"
@click="onGifsTabSelected"
>
{{ $t("gifs.tab_gifs") }}
</button>
</div>
<div
v-show="emojiStickerTab === 'emoji'"
@@ -722,43 +752,143 @@
<input
ref="sticker-upload-input"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/bmp,.png,.jpg,.jpeg,.gif,.webp,.bmp"
accept="image/png,image/jpeg,image/gif,image/webp,image/bmp,video/webm,application/x-tgsticker,.png,.jpg,.jpeg,.gif,.webp,.bmp,.webm,.tgs"
multiple
class="hidden"
@change="onStickerUploadInputChange"
/>
<div v-if="userStickers.length > 0" class="grid grid-cols-4 gap-2 mb-2">
<div
v-if="userStickerPacks.length > 0"
class="flex shrink-0 gap-1 overflow-x-auto pb-2 mb-2 border-b border-gray-200 dark:border-zinc-800"
>
<button
v-for="s in userStickers"
type="button"
class="shrink-0 rounded-lg px-2 py-1 text-[11px] font-medium border"
:class="
activeStickerPackId === null
? 'bg-blue-100 dark:bg-blue-950/60 border-blue-300 dark:border-blue-700 text-blue-800 dark:text-blue-200'
: 'border-transparent text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800'
"
@click="activeStickerPackId = null"
>
{{ $t("sticker_packs.all") }}
</button>
<button
v-for="pack in userStickerPacks"
:key="pack.id"
type="button"
class="shrink-0 rounded-lg px-2 py-1 text-[11px] font-medium border max-w-[120px] truncate"
:class="
activeStickerPackId === pack.id
? 'bg-blue-100 dark:bg-blue-950/60 border-blue-300 dark:border-blue-700 text-blue-800 dark:text-blue-200'
: 'border-transparent text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800'
"
:title="pack.title"
@click="activeStickerPackId = pack.id"
>
{{ pack.title }}
</button>
</div>
<div v-if="visibleStickers.length > 0" class="grid grid-cols-4 gap-2 mb-2">
<button
v-for="s in visibleStickers"
:key="s.id"
type="button"
class="aspect-square rounded-lg overflow-hidden border border-gray-200 dark:border-zinc-700 hover:ring-2 hover:ring-blue-500/50"
:title="s.name || 'Sticker'"
class="aspect-square rounded-lg overflow-hidden border border-gray-200 dark:border-zinc-700 hover:ring-2 hover:ring-blue-500/50 bg-gray-50 dark:bg-zinc-800"
:title="s.name || s.emoji || 'Sticker'"
@click="addStickerFromLibrary(s)"
>
<img
:src="stickerImageUrl(s.id)"
class="w-full h-full object-contain bg-gray-50 dark:bg-zinc-800"
alt=""
/>
<StickerView :src="stickerImageUrl(s.id)" :image-type="s.image_type" />
</button>
</div>
<div
v-if="userStickers.length === 0"
v-if="visibleStickers.length === 0"
class="text-center text-sm text-gray-500 dark:text-zinc-400 mb-2 px-1"
>
{{ $t("stickers.empty_library") }}
</div>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-xl border border-gray-300 dark:border-zinc-600 px-2 py-2 text-xs flex items-center justify-center gap-1 hover:border-emerald-500"
@click="openStickerEditor()"
>
<MaterialDesignIcon icon-name="image-edit-outline" class="size-4" />
{{ $t("sticker_editor.create_button") }}
</button>
<button
type="button"
class="rounded-xl border-2 border-dashed border-gray-300 dark:border-zinc-600 px-2 py-2 text-xs hover:border-blue-400"
:class="
stickerDropActive
? 'border-blue-500 bg-blue-50/70 dark:bg-blue-950/40'
: ''
"
:disabled="isStickerUploading"
@click="triggerStickerUploadInput"
>
<div class="flex items-center justify-center gap-1">
<MaterialDesignIcon
icon-name="upload"
class="size-4 text-blue-500"
/>
{{ $t("stickers.upload_short") }}
</div>
</button>
</div>
</div>
<div
v-show="emojiStickerTab === 'gifs'"
class="min-h-0 flex-1 overflow-y-auto p-2"
role="tabpanel"
@dragover.prevent.stop="onGifPanelDragOver"
@dragleave.prevent.stop="onGifPanelDragLeave"
@drop.prevent.stop="onGifPanelDrop"
>
<input
ref="gif-upload-input"
type="file"
accept="image/gif,image/webp,.gif,.webp"
multiple
class="hidden"
@change="onGifUploadInputChange"
/>
<div v-if="userGifs.length > 0" class="grid grid-cols-2 gap-2 mb-2">
<button
v-for="g in userGifs"
:key="g.id"
type="button"
class="relative aspect-video rounded-lg overflow-hidden border border-gray-200 dark:border-zinc-700 hover:ring-2 hover:ring-blue-500/50 group"
:title="g.name || 'GIF'"
@click="addGifFromLibrary(g)"
>
<InViewAnimatedImg
:src="gifImageUrl(g.id)"
fit-parent
img-class="w-full h-full object-contain bg-gray-50 dark:bg-zinc-800"
/>
<span
v-if="g.usage_count > 0"
class="pointer-events-none absolute bottom-1 right-1 rounded-full bg-black/60 text-white text-[10px] px-1.5 py-0.5"
>
{{ g.usage_count }}
</span>
</button>
</div>
<div
v-if="userGifs.length === 0"
class="text-center text-sm text-gray-500 dark:text-zinc-400 mb-2 px-1"
>
{{ $t("gifs.empty_library") }}
</div>
<button
type="button"
class="w-full rounded-xl border-2 border-dashed border-gray-300 dark:border-zinc-600 px-3 py-3 text-left transition-colors hover:border-blue-400 hover:bg-blue-50/60 dark:hover:bg-blue-950/30"
:class="
stickerDropActive
? 'border-blue-500 bg-blue-50/70 dark:bg-blue-950/40'
: ''
gifDropActive ? 'border-blue-500 bg-blue-50/70 dark:bg-blue-950/40' : ''
"
:disabled="isStickerUploading"
@click="triggerStickerUploadInput"
:disabled="isGifUploading"
@click="triggerGifUploadInput"
>
<div class="flex items-start gap-2">
<MaterialDesignIcon
@@ -768,13 +898,13 @@
<div class="min-w-0">
<div class="text-xs font-medium text-gray-800 dark:text-zinc-100">
{{
userStickers.length > 0
? $t("stickers.add_more_hint")
: $t("stickers.drop_or_click_hint")
userGifs.length > 0
? $t("gifs.add_more_hint")
: $t("gifs.drop_or_click_hint")
}}
</div>
<div
v-if="isStickerUploading"
v-if="isGifUploading"
class="text-[11px] text-blue-600 dark:text-blue-400 mt-1"
>
{{ $t("common.loading") }}
@@ -839,15 +969,6 @@
<span class="hidden sm:inline">Paste</span>
</button>
<AddImageButton @add-image="onImageSelected" />
<AddAudioButton
:is-recording-audio-attachment="isRecordingAudioAttachment"
@start-recording="startRecordingAudioAttachment($event)"
@stop-recording="stopRecordingAudioAttachment"
>
<span>{{
$t("messages.recording", { duration: audioAttachmentRecordingDuration })
}}</span>
</AddAudioButton>
<button
type="button"
class="attachment-action-button"
@@ -977,6 +1098,13 @@
<MaterialDesignIcon icon-name="bookmark-plus-outline" class="size-4 text-teal-500" />
{{ $t("stickers.save_to_library") }}
</ContextMenuItem>
<ContextMenuItem
v-if="canSaveMessageImageAsGif(messageContextMenu.chatItem)"
@click="saveMessageImageToGifs(messageContextMenu.chatItem)"
>
<MaterialDesignIcon icon-name="file-gif-box" class="size-4 text-pink-500" />
{{ $t("gifs.save_to_library") }}
</ContextMenuItem>
<ContextMenuItem
v-if="
messageContextMenu.chatItem?.is_outbound &&
@@ -1287,6 +1415,13 @@
@close="isPaperMessageModalOpen = false"
/>
<StickerEditor
:visible="isStickerEditorOpen"
:default-pack-id="activeStickerPackId"
@close="closeStickerEditor"
@saved="onStickerEditorSaved"
/>
<PaperMessageModal
v-if="isPaperMessageResultModalOpen"
:initial-uri="generatedPaperMessageUri"
@@ -1531,6 +1666,9 @@ import { COLUMBA_REACTION_EMOJIS, mergeLxmfReactionRowsIntoMessages } from "../.
import { createOutboundQueue } from "../../js/outboundSendQueue";
import emojiPickerEnDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url";
import "emoji-picker-element";
import StickerView from "../stickers/StickerView.vue";
import StickerEditor from "../stickers/StickerEditor.vue";
import InViewAnimatedImg from "./InViewAnimatedImg.vue";
export default {
name: "ConversationViewer",
@@ -1548,6 +1686,9 @@ export default {
LxmfUserIcon,
ConversationMessageEntry,
ConversationMessageListVirtual,
StickerView,
StickerEditor,
InViewAnimatedImg,
},
props: {
config: {
@@ -1655,12 +1796,18 @@ export default {
reactionPickerPos: null,
reactionDragState: null,
userStickers: [],
userStickerPacks: [],
activeStickerPackId: null,
isStickerEditorOpen: false,
isStickerPickerOpen: false,
emojiStickerTab: "emoji",
emojiPickerDataUrl: emojiPickerEnDataUrl,
stickerDropActive: false,
composerImageDropActive: false,
isStickerUploading: false,
userGifs: [],
gifDropActive: false,
isGifUploading: false,
now: Date.now(),
updateTimer: null,
sendStatusUiMs: Date.now(),
@@ -1675,6 +1822,12 @@ export default {
};
},
computed: {
visibleStickers() {
if (this.activeStickerPackId === null) {
return this.userStickers;
}
return this.userStickers.filter((s) => s.pack_id === this.activeStickerPackId);
},
compactPeerActions() {
return this.windowWidth < 640 || this.peerHeaderCompact;
},
@@ -4486,11 +4639,6 @@ export default {
this.$refs["file-input"].value = null;
},
async removeImageAttachment(index) {
// ask user to confirm removing image attachment
if (!(await DialogUtils.confirm(this.$t("messages.remove_image_confirm")))) {
return;
}
// remove image
this.newMessageImages.splice(index, 1);
this.newMessageImageUrls.splice(index, 1);
@@ -4512,6 +4660,7 @@ export default {
onStickerPickerClickOutside() {
this.isStickerPickerOpen = false;
this.stickerDropActive = false;
this.gifDropActive = false;
},
toggleStickerPicker() {
if (!this.isStickerPickerOpen) {
@@ -4553,10 +4702,26 @@ export default {
} catch {
this.userStickers = [];
}
try {
const r = await window.api.get("/api/v1/sticker-packs");
this.userStickerPacks = r.data?.packs ?? [];
} catch {
this.userStickerPacks = [];
}
},
stickerImageUrl(stickerId) {
return `/api/v1/stickers/${stickerId}/image`;
},
openStickerEditor() {
this.isStickerEditorOpen = true;
this.isStickerPickerOpen = false;
},
closeStickerEditor() {
this.isStickerEditorOpen = false;
},
async onStickerEditorSaved() {
await this.loadUserStickers();
},
onStickerPanelDragOver(event) {
event.preventDefault();
if (event.dataTransfer) {
@@ -4600,16 +4765,31 @@ export default {
"image/webp": "webp",
"image/bmp": "bmp",
"image/x-ms-bmp": "bmp",
"video/webm": "webm",
"application/x-tgsticker": "tgs",
};
if (map[m]) {
return map[m];
}
const ext = (name.split(".").pop() || "").toLowerCase();
const extMap = { png: "png", jpg: "jpeg", jpeg: "jpeg", gif: "gif", webp: "webp", bmp: "bmp" };
const extMap = {
png: "png",
jpg: "jpeg",
jpeg: "jpeg",
gif: "gif",
webp: "webp",
bmp: "bmp",
webm: "webm",
tgs: "tgs",
};
return extMap[ext] || null;
},
stickerTypeMaxBytes(type) {
if (type === "tgs") return 64 * 1024;
if (type === "webm") return 256 * 1024;
return 512 * 1024;
},
async uploadStickerImageFiles(fileList) {
const maxBytes = 512 * 1024;
const files = Array.from(fileList || []).filter((f) => f && f.size > 0);
if (files.length === 0) {
return;
@@ -4620,25 +4800,32 @@ export default {
let failed = 0;
try {
for (const file of files) {
if (file.size > maxBytes) {
ToastUtils.error(this.$t("stickers.file_too_large"));
failed++;
continue;
}
const imageType = this.mimeToStickerType(file.type, file.name);
if (!imageType) {
ToastUtils.error(this.$t("stickers.unsupported_type"));
failed++;
continue;
}
if (file.size > this.stickerTypeMaxBytes(imageType)) {
ToastUtils.error(this.$t("stickers.file_too_large"));
failed++;
continue;
}
try {
const buf = await file.arrayBuffer();
const imageBytes = Utils.arrayBufferToBase64(buf);
await window.api.post("/api/v1/stickers", {
const payload = {
image_bytes: imageBytes,
image_type: imageType,
name: null,
});
};
if (imageType === "tgs" || imageType === "webm") {
payload.strict = true;
}
if (this.activeStickerPackId !== null) {
payload.pack_id = this.activeStickerPackId;
}
await window.api.post("/api/v1/stickers", payload);
added++;
} catch (e) {
const err = e?.response?.data?.error;
@@ -4721,6 +4908,203 @@ export default {
}
}
},
onGifsTabSelected() {
this.emojiStickerTab = "gifs";
this.loadUserGifs();
},
async loadUserGifs() {
try {
const r = await window.api.get("/api/v1/gifs");
this.userGifs = r.data?.gifs ?? [];
} catch {
this.userGifs = [];
}
},
gifImageUrl(gifId) {
return `/api/v1/gifs/${gifId}/image`;
},
onGifPanelDragOver(event) {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy";
}
this.gifDropActive = true;
},
onGifPanelDragLeave(event) {
const el = event.currentTarget;
if (el && event.relatedTarget && el.contains(event.relatedTarget)) {
return;
}
this.gifDropActive = false;
},
onGifPanelDrop(event) {
event.preventDefault();
this.gifDropActive = false;
const files = event.dataTransfer?.files;
if (files?.length) {
this.uploadGifFiles(files);
}
},
triggerGifUploadInput() {
const input = this.$refs["gif-upload-input"];
if (input) input.click();
},
onGifUploadInputChange(event) {
const files = event.target.files;
if (files?.length) {
this.uploadGifFiles(files);
}
event.target.value = "";
},
mimeToGifType(mime, name = "") {
const m = (mime || "").toLowerCase().split(";")[0].trim();
const map = {
"image/gif": "gif",
"image/webp": "webp",
};
if (map[m]) {
return map[m];
}
const ext = (name.split(".").pop() || "").toLowerCase();
const extMap = { gif: "gif", webp: "webp" };
return extMap[ext] || null;
},
async uploadGifFiles(fileList) {
const maxBytes = 5 * 1024 * 1024;
const files = Array.from(fileList || []).filter((f) => f && f.size > 0);
if (files.length === 0) {
return;
}
this.isGifUploading = true;
let added = 0;
let dup = 0;
let failed = 0;
try {
for (const file of files) {
if (file.size > maxBytes) {
ToastUtils.error(this.$t("gifs.file_too_large"));
failed++;
continue;
}
const imageType = this.mimeToGifType(file.type, file.name);
if (!imageType) {
ToastUtils.error(this.$t("gifs.unsupported_type"));
failed++;
continue;
}
try {
const buf = await file.arrayBuffer();
const imageBytes = Utils.arrayBufferToBase64(buf);
await window.api.post("/api/v1/gifs", {
image_bytes: imageBytes,
image_type: imageType,
name: null,
});
added++;
} catch (e) {
const err = e?.response?.data?.error;
if (err === "duplicate_gif") {
dup++;
} else {
failed++;
console.error(e);
}
}
}
await this.loadUserGifs();
if (added > 0) {
ToastUtils.success(this.$t("gifs.uploaded_count", { count: added }));
}
if (dup > 0 && added === 0 && failed === 0) {
ToastUtils.info(this.$t("gifs.duplicate"));
} else if (dup > 0 && added > 0) {
ToastUtils.info(this.$t("gifs.duplicate"));
}
if (failed > 0 && added === 0 && dup === 0) {
ToastUtils.error(this.$t("gifs.save_failed"));
}
} finally {
this.isGifUploading = false;
}
},
async addGifFromLibrary(gif) {
try {
const res = await window.api.get(`/api/v1/gifs/${gif.id}/image`, {
responseType: "blob",
});
const blob = res.data;
const ext = gif.image_type;
const mime = blob.type || `image/${gif.image_type}`;
const file = new File([blob], `gif-${gif.id}.${ext}`, { type: mime });
this.onImageSelected(file);
this.isStickerPickerOpen = false;
window.api.post(`/api/v1/gifs/${gif.id}/use`).catch(() => {});
const idx = this.userGifs.findIndex((g) => g.id === gif.id);
if (idx >= 0) {
const updated = { ...this.userGifs[idx], usage_count: (this.userGifs[idx].usage_count || 0) + 1 };
this.userGifs.splice(idx, 1);
let inserted = false;
for (let i = 0; i < this.userGifs.length; i++) {
if ((this.userGifs[i].usage_count || 0) <= updated.usage_count) {
this.userGifs.splice(i, 0, updated);
inserted = true;
break;
}
}
if (!inserted) this.userGifs.push(updated);
}
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("gifs.save_failed"));
}
},
canSaveMessageImageAsGif(chatItem) {
const img = chatItem?.lxmf_message?.fields?.image;
if (!img) return false;
const t = String(img.image_type || "")
.toLowerCase()
.replace(/^image\//, "");
return t === "gif" || t === "webp";
},
async saveMessageImageToGifs(chatItem) {
this.messageContextMenu.show = false;
const msg = chatItem.lxmf_message;
const img = msg.fields?.image;
if (!img) {
return;
}
let b64 = img.image_bytes;
if (!b64) {
try {
const res = await window.api.get(`/api/v1/lxmf-messages/attachment/${msg.hash}/image`, {
responseType: "arraybuffer",
});
b64 = Utils.arrayBufferToBase64(res.data);
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("gifs.save_failed"));
return;
}
}
const imageType = String(img.image_type || "gif").replace(/^image\//, "");
try {
await window.api.post("/api/v1/gifs", {
image_bytes: b64,
image_type: imageType,
source_message_hash: msg.hash,
name: null,
});
ToastUtils.success(this.$t("gifs.saved"));
await this.loadUserGifs();
} catch (e) {
const err = e?.response?.data?.error;
if (err === "duplicate_gif") {
ToastUtils.info(this.$t("gifs.duplicate"));
} else {
ToastUtils.error(this.$t("gifs.save_failed"));
}
}
},
async startRecordingAudioAttachment(args) {
// do nothing if already recording
if (this.isRecordingAudioAttachment) {
@@ -4865,11 +5249,6 @@ export default {
}
},
async removeAudioAttachment() {
// ask user to confirm removing audio attachment
if (!(await DialogUtils.confirm(this.$t("messages.remove_audio_confirm")))) {
return;
}
// remove audio
this.newMessageAudio = null;
},

View File

@@ -0,0 +1,65 @@
<template>
<div ref="wrap" :class="wrapClass">
<img
v-if="show"
:src="src"
:loading="loading"
:decoding="decoding"
:class="imgClass"
:alt="alt"
draggable="false"
@click="$emit('click', $event)"
/>
<div v-else :class="placeholderClassComputed" aria-hidden="true" />
</div>
</template>
<script>
import { attachInView } from "../../js/inViewObserver.js";
export default {
name: "InViewAnimatedImg",
props: {
src: { type: String, required: true },
imgClass: { type: String, default: "" },
fitParent: { type: Boolean, default: false },
alt: { type: String, default: "" },
loading: { type: String, default: "lazy" },
decoding: { type: String, default: "async" },
},
emits: ["click"],
data() {
return {
show: false,
ioCleanup: null,
};
},
computed: {
wrapClass() {
return this.fitParent ? "absolute inset-0 overflow-hidden" : "relative w-full";
},
placeholderClassComputed() {
if (this.fitParent) {
return "absolute inset-0 bg-zinc-200/30 dark:bg-white/10";
}
return "min-h-[8rem] w-full rounded-2xl bg-gray-100/90 dark:bg-zinc-800/60";
},
},
mounted() {
this.$nextTick(() => {
const el = this.$refs.wrap;
if (!el) {
return;
}
this.ioCleanup = attachInView(el, (entry) => {
this.show = entry.isIntersecting;
});
});
},
beforeUnmount() {
if (this.ioCleanup) {
this.ioCleanup();
}
},
};
</script>

View File

@@ -63,6 +63,84 @@
/>
</div>
<button
v-if="!isPopoutMode && !destinationHash"
type="button"
class="sm:hidden fixed bottom-5 right-4 z-[65] flex h-14 w-14 items-center justify-center rounded-full bg-zinc-900 text-white shadow-lg ring-1 ring-white/10 transition active:scale-95 dark:bg-zinc-100 dark:text-zinc-900 dark:ring-zinc-800"
:title="$t('app.compose')"
@click="openMobileCompose"
>
<MaterialDesignIcon icon-name="plus" class="size-7" />
</button>
<div
v-if="isMobileComposeModalOpen"
class="fixed inset-0 z-[95] flex items-end justify-center sm:items-center p-0 sm:p-4 bg-black/50 backdrop-blur-sm sm:bg-black/50"
@click.self="isMobileComposeModalOpen = false"
>
<div
class="w-full sm:max-w-md bg-white dark:bg-zinc-900 rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col"
@click.stop
>
<div
class="px-5 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between shrink-0"
>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">
{{ $t("messages.mobile_compose_title") }}
</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center -mr-2"
@click="isMobileComposeModalOpen = false"
>
<MaterialDesignIcon icon-name="close" class="size-6" />
</button>
</div>
<div class="p-5 overflow-y-auto space-y-4">
<p class="text-sm text-gray-600 dark:text-zinc-400">
{{ $t("messages.select_peer_or_enter_address") }}
</p>
<div>
<label
class="block text-xs font-medium text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
for="mobile-compose-destination"
>
{{ $t("app.lxmf_address_hash") }}
</label>
<input
id="mobile-compose-destination"
v-model="mobileComposeAddress"
type="text"
autocomplete="off"
autocorrect="off"
spellcheck="false"
:placeholder="$t('messages.mobile_compose_destination_placeholder')"
class="block w-full rounded-xl border-0 py-2.5 px-3 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"
@keydown.enter="submitMobileCompose"
/>
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="w-full flex justify-center items-center gap-2 py-2.5 px-4 border border-transparent rounded-xl shadow-sm text-sm font-bold text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all disabled:opacity-50 disabled:pointer-events-none"
:disabled="!mobileComposeAddress.trim()"
@click="submitMobileCompose"
>
{{ $t("app.compose") }}
</button>
<button
type="button"
class="w-full flex justify-center items-center gap-2 py-2.5 px-4 rounded-xl border border-gray-200 dark:border-zinc-700 text-sm font-semibold text-gray-800 dark:text-zinc-200 bg-gray-50 dark:bg-zinc-800 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
@click="openIngestFromMobileCompose"
>
<MaterialDesignIcon icon-name="qrcode" class="size-5 shrink-0" />
{{ $t("messages.ingest_paper_message") }}
</button>
</div>
</div>
</div>
</div>
<!-- Ingest Paper Message Modal -->
<div
v-if="isIngestModalOpen"
@@ -109,6 +187,15 @@
>
<MaterialDesignIcon icon-name="clipboard-text-outline" class="size-5" />
</button>
<button
v-if="cameraSupported"
type="button"
class="px-3 py-2 bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
:title="$t('messages.scan_qr')"
@click="openIngestScannerModal"
>
<MaterialDesignIcon icon-name="qrcode-scan" class="size-5" />
</button>
</div>
</div>
<button
@@ -119,6 +206,40 @@
>
Read LXM
</button>
<p v-if="!cameraSupported" class="text-xs text-gray-500 dark:text-zinc-400">
{{ $t("messages.camera_not_supported") }}
</p>
</div>
</div>
</div>
</div>
<div
v-if="isIngestScannerModalOpen"
class="fixed inset-0 z-[120] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
@click.self="closeIngestScannerModal"
>
<div class="w-full max-w-xl rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl overflow-hidden">
<div class="px-5 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-zinc-100">{{ $t("messages.scan_qr") }}</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
@click="closeIngestScannerModal"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="p-5 space-y-3">
<video
ref="ingestScannerVideo"
class="w-full rounded-xl bg-black max-h-[60vh]"
autoplay
playsinline
muted
></video>
<div class="text-sm text-gray-500 dark:text-zinc-400">
{{ ingestScannerError || $t("messages.scanner_hint") }}
</div>
</div>
</div>
@@ -191,6 +312,12 @@ export default {
isIngestModalOpen: false,
ingestUri: "",
isIngestScannerModalOpen: false,
ingestScannerError: null,
ingestScannerStream: null,
ingestScannerAnimationFrame: null,
isMobileComposeModalOpen: false,
mobileComposeAddress: "",
};
},
computed: {
@@ -210,6 +337,13 @@ export default {
messagesSidebarOnRight() {
return this.messagesSidebarPosition === "right";
},
cameraSupported() {
return (
typeof window !== "undefined" &&
typeof window.BarcodeDetector !== "undefined" &&
navigator?.mediaDevices?.getUserMedia
);
},
},
watch: {
conversations() {
@@ -220,6 +354,7 @@ export default {
},
destinationHash(newHash) {
if (newHash) {
this.isMobileComposeModalOpen = false;
this.onComposeNewMessage(newHash);
}
},
@@ -230,6 +365,7 @@ export default {
clearTimeout(this.peersRefreshTimeout);
this.conversationsAbortController?.abort();
this.announcesAbortController?.abort();
this.stopIngestScanner();
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
@@ -847,6 +983,89 @@ export default {
this.ingestUri = "";
this.isIngestModalOpen = true;
},
async openIngestScannerModal() {
this.ingestScannerError = null;
this.isIngestScannerModalOpen = true;
await this.$nextTick();
await this.startIngestScanner();
},
closeIngestScannerModal() {
this.isIngestScannerModalOpen = false;
this.stopIngestScanner();
},
async startIngestScanner() {
if (!this.cameraSupported) {
this.ingestScannerError = this.$t("messages.camera_not_supported");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
audio: false,
});
this.ingestScannerStream = stream;
const video = this.$refs.ingestScannerVideo;
if (!video) {
this.ingestScannerError = this.$t("messages.camera_failed");
this.stopIngestScanner();
return;
}
video.srcObject = stream;
await video.play();
this.detectIngestQrLoop();
} catch (e) {
this.ingestScannerError = this.describeCameraError(e);
}
},
detectIngestQrLoop() {
if (!this.isIngestScannerModalOpen) return;
const video = this.$refs.ingestScannerVideo;
if (!video || video.readyState < 2) {
this.ingestScannerAnimationFrame = requestAnimationFrame(() => this.detectIngestQrLoop());
return;
}
const detector = new window.BarcodeDetector({ formats: ["qr_code"] });
detector
.detect(video)
.then((barcodes) => {
const qr = barcodes?.[0]?.rawValue?.trim();
if (!qr) {
this.ingestScannerAnimationFrame = requestAnimationFrame(() => this.detectIngestQrLoop());
return;
}
if (!/^lxm(a|f)?:\/\//i.test(qr)) {
ToastUtils.error(this.$t("messages.invalid_qr_uri"));
this.ingestScannerAnimationFrame = requestAnimationFrame(() => this.detectIngestQrLoop());
return;
}
this.ingestUri = qr;
this.closeIngestScannerModal();
this.ingestPaperMessage();
})
.catch(() => {
this.ingestScannerAnimationFrame = requestAnimationFrame(() => this.detectIngestQrLoop());
});
},
stopIngestScanner() {
if (this.ingestScannerAnimationFrame) {
cancelAnimationFrame(this.ingestScannerAnimationFrame);
this.ingestScannerAnimationFrame = null;
}
if (this.ingestScannerStream) {
this.ingestScannerStream.getTracks().forEach((track) => track.stop());
this.ingestScannerStream = null;
}
},
describeCameraError(error) {
const name = error?.name || "";
if (name === "NotAllowedError" || name === "SecurityError") {
return this.$t("messages.camera_permission_denied");
}
if (name === "NotFoundError" || name === "DevicesNotFoundError") {
return this.$t("messages.camera_not_found");
}
return this.$t("messages.camera_failed");
},
async pasteFromClipboard() {
try {
this.ingestUri = await navigator.clipboard.readText();
@@ -874,6 +1093,23 @@ export default {
const match = hash.match(/popout=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
},
openMobileCompose() {
this.mobileComposeAddress = "";
this.isMobileComposeModalOpen = true;
},
openIngestFromMobileCompose() {
this.isMobileComposeModalOpen = false;
this.openIngestPaperMessageModal();
},
async submitMobileCompose() {
const raw = this.mobileComposeAddress.trim();
if (!raw) {
return;
}
this.isMobileComposeModalOpen = false;
this.mobileComposeAddress = "";
await this.onComposeNewMessage(raw.replace(/^lxmf@/, ""));
},
},
};
</script>

View File

@@ -10,7 +10,7 @@
]"
>
<div
class="hidden sm:flex h-12 shrink-0 items-center border-b border-gray-200 dark:border-zinc-800 px-2"
class="hidden sm:flex h-10 shrink-0 items-center border-b border-gray-200 dark:border-zinc-800 px-2"
:class="collapsedHeaderJustifyClass"
>
<button
@@ -76,9 +76,9 @@
</div>
</div>
<template v-else>
<!-- tabs (h-12 matches App.vue main sidebar collapse row) -->
<!-- tabs (h-10 matches sidebar collapse row height) -->
<div :class="['bg-white dark:bg-zinc-950 border-b border-gray-200 dark:border-zinc-800', edgeBorderClass]">
<div class="-mb-px flex h-12 min-w-0 items-stretch" :class="{ 'flex-row-reverse': isRightSidebar }">
<div class="-mb-px flex h-10 min-w-0 items-stretch" :class="{ 'flex-row-reverse': isRightSidebar }">
<div class="flex min-w-0 flex-1">
<div
class="flex w-full cursor-pointer items-center justify-center border-b-2 px-1 text-center text-sm font-semibold uppercase tracking-wide transition"
@@ -274,16 +274,37 @@
@input="onConversationSearchInput"
/>
</div>
<div class="flex items-center justify-end gap-1 px-1 text-gray-500 dark:text-gray-400">
<div class="flex flex-wrap items-center gap-1">
<button
type="button"
class="p-1 rounded-lg hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
class="p-1 mr-1 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
title="Selection Mode"
:class="{ 'text-blue-500 dark:text-blue-400': selectionMode }"
@click="toggleSelectionMode"
>
<MaterialDesignIcon icon-name="checkbox-multiple-marked-outline" class="size-5" />
</button>
<button
type="button"
:class="filterChipClasses(filterUnreadOnly)"
@click="toggleFilter('unread')"
>
{{ $t("messages.unread") }}
</button>
<button
type="button"
:class="filterChipClasses(filterFailedOnly)"
@click="toggleFilter('failed')"
>
{{ $t("messages.failed") }}
</button>
<button
type="button"
:class="filterChipClasses(filterHasAttachmentsOnly)"
@click="toggleFilter('attachments')"
>
{{ $t("messages.attachments") }}
</button>
</div>
<div
v-if="selectionMode"
@@ -348,29 +369,6 @@
</div>
</div>
</div>
<div class="flex flex-wrap gap-1">
<button
type="button"
:class="filterChipClasses(filterUnreadOnly)"
@click="toggleFilter('unread')"
>
{{ $t("messages.unread") }}
</button>
<button
type="button"
:class="filterChipClasses(filterFailedOnly)"
@click="toggleFilter('failed')"
>
{{ $t("messages.failed") }}
</button>
<button
type="button"
:class="filterChipClasses(filterHasAttachmentsOnly)"
@click="toggleFilter('attachments')"
>
{{ $t("messages.attachments") }}
</button>
</div>
</div>
<!-- conversations -->
@@ -414,11 +412,11 @@
: '',
]"
draggable="true"
@click="
selectionMode
? toggleSelectConversation(conversation.destination_hash)
: onConversationClick(conversation)
"
@click="onConversationRowActivate(conversation)"
@touchstart.passive="onConversationTouchStart($event, conversation)"
@touchmove="onConversationTouchMove"
@touchend="onConversationTouchEnd"
@touchcancel="onConversationTouchEnd"
@contextmenu="onRightClick($event, conversation.destination_hash)"
@dragstart="onDragStart($event, conversation.destination_hash)"
>
@@ -935,6 +933,8 @@ export default {
draggedHash: null,
dragOverFolderId: null,
smUp: typeof window !== "undefined" ? window.innerWidth >= 640 : true,
conversationLongPressTimer: null,
conversationLongPressFired: false,
};
},
computed: {
@@ -1068,6 +1068,7 @@ export default {
this._smUpMql.removeEventListener("change", this._smUpResize);
}
if (this._timeAgoInterval) clearInterval(this._timeAgoInterval);
this.clearConversationLongPressTimer();
},
methods: {
onContactUpdated(data) {
@@ -1268,6 +1269,46 @@ export default {
}
this.contextMenu.show = false;
},
onConversationRowActivate(conversation) {
if (this.conversationLongPressFired) {
this.conversationLongPressFired = false;
return;
}
if (this.selectionMode) {
this.toggleSelectConversation(conversation.destination_hash);
return;
}
this.onConversationClick(conversation);
},
onConversationTouchStart(_event, conversation) {
if (this.smUp || this.selectionMode || this.isBlocked(conversation.destination_hash)) {
return;
}
this.clearConversationLongPressTimer();
this.conversationLongPressTimer = setTimeout(() => {
this.conversationLongPressTimer = null;
this.conversationLongPressFired = true;
if (!this.selectionMode) {
this.selectionMode = true;
this.selectedHashes.add(conversation.destination_hash);
}
if (typeof navigator !== "undefined" && navigator.vibrate) {
navigator.vibrate(25);
}
}, 500);
},
onConversationTouchMove() {
this.clearConversationLongPressTimer();
},
onConversationTouchEnd() {
this.clearConversationLongPressTimer();
},
clearConversationLongPressTimer() {
if (this.conversationLongPressTimer != null) {
clearTimeout(this.conversationLongPressTimer);
this.conversationLongPressTimer = null;
}
},
onConversationClick(conversation) {
if (this.isBlocked(conversation.destination_hash)) {
return;

View File

@@ -191,12 +191,16 @@
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
<span>{{ $t("nomadnet.pop_out_browser") }}</span>
</DropDownMenuItem>
<DropDownMenuItem @click="onCloseNodeViewer">
<MaterialDesignIcon icon-name="close" class="size-5" />
<span>{{ $t("common.cancel") }}</span>
</DropDownMenuItem>
</template>
</DropDownMenu>
<IconButton
class="lg:hidden shrink-0 text-gray-700 dark:text-gray-300"
:title="$t('common.cancel')"
@click="onCloseNodeViewer"
>
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
</IconButton>
</div>
<!-- browser navigation -->
@@ -1919,6 +1923,27 @@ export default {
font-feature-settings: inherit;
}
/*
* Mobile-only: allow horizontal scrolling for micron pages so ASCII art and
* fixed-width content do not get word-wrapped and broken up. Markdown and HTML
* rendered content keep their natural wrap behaviour.
*/
@media (max-width: 640px) {
.nodeContainer {
overflow-x: auto;
}
.nodeContainer .Mu-mws {
flex-wrap: nowrap;
}
.nodeContainer pre,
.nodeContainer .mu-parse-fallback,
.nodeContainer .mu-line-parse-fallback {
white-space: pre;
}
}
pre.text-wrap > div {
display: flex;
white-space: pre;

View File

@@ -7,7 +7,7 @@
class="flex flex-col h-full min-h-0 bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-800"
>
<div
class="hidden sm:flex h-12 shrink-0 items-center justify-end border-b border-gray-200 dark:border-zinc-800 px-2"
class="hidden sm:flex h-10 shrink-0 items-center justify-end border-b border-gray-200 dark:border-zinc-800 px-2"
>
<button
type="button"
@@ -86,7 +86,7 @@
</div>
<template v-else>
<div
class="flex h-12 min-w-0 items-stretch border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950"
class="-mb-px flex h-10 min-w-0 items-stretch border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950"
>
<div class="flex min-w-0 flex-1">
<button
@@ -172,32 +172,44 @@
@drop.prevent="onSectionDrop(section.id)"
@dragend="onSectionDragEnd"
>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-1 min-w-0">
<MaterialDesignIcon
:icon-name="section.collapsed ? 'chevron-right' : 'chevron-down'"
class="size-4 text-gray-400"
class="size-4 text-gray-400 shrink-0"
/>
<template v-if="editingSectionId === section.id">
<input
:ref="`sectionInput-${section.id}`"
v-model="editingSectionName"
type="text"
class="flex-1 bg-transparent border-b border-blue-500 text-xs font-semibold uppercase tracking-wide text-gray-900 dark:text-white focus:outline-none min-w-0"
@click.stop
@keydown.enter="saveSectionName"
@keydown.esc="cancelEditingSection"
@blur="saveSectionName"
/>
<button
type="button"
class="p-1 text-green-500 hover:text-green-600 shrink-0"
@click.stop="saveSectionName"
>
<MaterialDesignIcon icon-name="check" class="size-4" />
</button>
</template>
<span
class="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300"
v-else
class="text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 truncate"
@click.stop="startEditingSection(section)"
>
{{ section.name }}
</span>
<span
v-if="section.collapsed"
class="text-[10px] font-semibold text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-zinc-800 px-2 py-0.5 rounded-full"
class="text-[10px] font-semibold text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-zinc-800 px-2 py-0.5 rounded-full shrink-0"
>
{{ section.favourites.length }}
</span>
</div>
<div class="flex items-center gap-1" @click.stop>
<button
type="button"
class="p-1 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 rounded-lg transition"
@click="openSectionContextMenu($event, section)"
>
<MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
</button>
</div>
</div>
<div class="h-px bg-gray-200 dark:bg-zinc-800 mx-1"></div>
<div v-if="!section.collapsed" class="space-y-2 pt-2 pb-1 px-1">
@@ -614,6 +626,8 @@ export default {
justOpened: false,
},
smUp: typeof window !== "undefined" ? window.innerWidth >= 640 : true,
editingSectionId: null,
editingSectionName: "",
};
},
computed: {
@@ -723,6 +737,28 @@ export default {
}
},
methods: {
startEditingSection(section) {
this.editingSectionId = section.id;
this.editingSectionName = section.name;
this.$nextTick(() => {
const el = this.$refs[`sectionInput-${section.id}`];
if (el && el[0]) el[0].focus();
});
},
saveSectionName() {
if (!this.editingSectionId) return;
const sectionId = this.editingSectionId;
const name = this.editingSectionName.trim();
if (name) {
this.sections = this.sections.map((sec) => (sec.id === sectionId ? { ...sec, name } : sec));
this.persistFavouriteLayout();
}
this.cancelEditingSection();
},
cancelEditingSection() {
this.editingSectionId = null;
this.editingSectionName = "";
},
matchesFavouriteSearch(favourite, searchTerm = this.favouritesSearchTerm.toLowerCase()) {
const matchesDisplayName = favourite.display_name.toLowerCase().includes(searchTerm);
const matchesCustomDisplayName =

View File

@@ -152,16 +152,44 @@
</div>
<div v-else class="text-xs text-gray-500 dark:text-zinc-500">Node stats appear when running.</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<label class="text-xs text-gray-600 dark:text-zinc-400">
Delivery transfer limit (MB)
<input
v-model.number="deliveryLimitInputMb"
type="number"
min="0.001"
step="0.01"
<label class="text-xs text-gray-600 dark:text-zinc-400 block">
{{ $t("app.incoming_message_size") }}
<span class="block mt-0.5 font-normal text-[11px] text-gray-500 dark:text-zinc-500">{{
$t("app.incoming_message_size_description")
}}</span>
<select
v-model="lxmfIncomingDeliveryPreset"
class="mt-1 w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
@input="onDeliveryTransferLimitChange"
/>
@change="onLxmfIncomingDeliveryPresetChange"
>
<option value="1mb">{{ $t("app.incoming_message_size_1mb") }}</option>
<option value="10mb">{{ $t("app.incoming_message_size_10mb") }}</option>
<option value="25mb">{{ $t("app.incoming_message_size_25mb") }}</option>
<option value="50mb">{{ $t("app.incoming_message_size_50mb") }}</option>
<option value="1gb">{{ $t("app.incoming_message_size_1gb") }}</option>
<option value="custom">{{ $t("app.incoming_message_size_custom") }}</option>
</select>
<div
v-if="lxmfIncomingDeliveryPreset === 'custom'"
class="mt-1 flex flex-wrap items-center gap-2"
>
<input
v-model.number="lxmfIncomingDeliveryCustomAmount"
type="number"
min="0.001"
step="any"
class="min-w-0 flex-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
@input="onLxmfIncomingDeliveryCustomChange"
/>
<select
v-model="lxmfIncomingDeliveryCustomUnit"
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl px-3 py-2"
@change="onLxmfIncomingDeliveryCustomChange"
>
<option value="mb">{{ $t("app.incoming_message_size_unit_mb") }}</option>
<option value="gb">{{ $t("app.incoming_message_size_unit_gb") }}</option>
</select>
</div>
<div class="mt-1 text-[11px] text-gray-500 dark:text-zinc-500">
{{ formatByteSize(config.lxmf_delivery_transfer_limit_in_bytes) }}
</div>
@@ -486,6 +514,11 @@ import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection";
import ToastUtils from "../../js/ToastUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import {
incomingDeliveryBytesFromCustom,
incomingDeliveryBytesFromPresetKey,
syncIncomingDeliveryFieldsFromBytes,
} from "../../js/settings/incomingDeliveryLimit";
export default {
name: "PropagationNodesPage",
@@ -515,7 +548,9 @@ export default {
},
isLocalManagerCollapsed: false,
localNodeDisplayNameDraft: "",
deliveryLimitInputMb: 0,
lxmfIncomingDeliveryPreset: "10mb",
lxmfIncomingDeliveryCustomAmount: 10,
lxmfIncomingDeliveryCustomUnit: "mb",
propagationLimitInputMb: 0,
propagationSyncLimitInputMb: 0,
nodePathsByHash: {},
@@ -779,7 +814,10 @@ export default {
syncManagerInputsFromConfig() {
const displayName = (this.config.display_name || "").trim();
this.localNodeDisplayNameDraft = displayName || "Anonymous Peer";
this.deliveryLimitInputMb = this.bytesToMb(this.config.lxmf_delivery_transfer_limit_in_bytes);
const incoming = syncIncomingDeliveryFieldsFromBytes(this.config.lxmf_delivery_transfer_limit_in_bytes);
this.lxmfIncomingDeliveryPreset = incoming.preset;
this.lxmfIncomingDeliveryCustomAmount = incoming.customAmount;
this.lxmfIncomingDeliveryCustomUnit = incoming.customUnit;
this.propagationLimitInputMb = this.bytesToMb(this.config.lxmf_propagation_transfer_limit_in_bytes);
this.propagationSyncLimitInputMb = this.bytesToMb(this.config.lxmf_propagation_sync_limit_in_bytes);
},
@@ -847,11 +885,32 @@ export default {
const iface = path.next_hop_interface || "unknown interface";
return `${hopsText} via ${iface}`;
},
onDeliveryTransferLimitChange() {
async onLxmfIncomingDeliveryPresetChange() {
if (this.lxmfIncomingDeliveryPreset === "custom") {
const incoming = syncIncomingDeliveryFieldsFromBytes(this.config.lxmf_delivery_transfer_limit_in_bytes);
this.lxmfIncomingDeliveryCustomAmount = incoming.customAmount;
this.lxmfIncomingDeliveryCustomUnit = incoming.customUnit;
return;
}
const bytes = incomingDeliveryBytesFromPresetKey(this.lxmfIncomingDeliveryPreset);
if (bytes == null) {
return;
}
await this.updateConfig({
lxmf_delivery_transfer_limit_in_bytes: bytes,
});
},
onLxmfIncomingDeliveryCustomChange() {
if (this.lxmfIncomingDeliveryPreset !== "custom") {
return;
}
if (this.saveTimeouts.deliveryLimit) clearTimeout(this.saveTimeouts.deliveryLimit);
this.saveTimeouts.deliveryLimit = setTimeout(async () => {
await this.updateConfig({
lxmf_delivery_transfer_limit_in_bytes: this.mbToBytes(this.deliveryLimitInputMb),
lxmf_delivery_transfer_limit_in_bytes: incomingDeliveryBytesFromCustom(
this.lxmfIncomingDeliveryCustomAmount,
this.lxmfIncomingDeliveryCustomUnit
),
});
}, 450);
},

View File

@@ -16,33 +16,34 @@
{{ $t("identities.manage") }}
</p>
</div>
<div
class="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-stretch sm:justify-end"
>
<div class="flex flex-row gap-2 sm:flex-wrap sm:items-stretch sm:justify-end">
<button
type="button"
class="inline-flex w-full items-center justify-center gap-x-2 min-h-[44px] rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all active:scale-[0.98] sm:w-auto sm:min-h-0 sm:rounded-2xl"
class="inline-flex items-center justify-center gap-x-2 rounded-xl bg-blue-600 p-2.5 sm:px-4 sm:py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all active:scale-[0.98] sm:rounded-2xl"
:title="$t('identities.new_identity')"
@click="showCreateModal = true"
>
<MaterialDesignIcon icon-name="plus" class="w-5 h-5 shrink-0" />
<span class="truncate">{{ $t("identities.new_identity") }}</span>
<span class="hidden sm:inline truncate">{{ $t("identities.new_identity") }}</span>
</button>
<button
type="button"
class="inline-flex w-full items-center justify-center gap-x-2 min-h-[44px] rounded-xl border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-4 py-2.5 text-sm font-semibold text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-700 transition-all active:scale-[0.98] sm:w-auto sm:min-h-0 sm:rounded-2xl"
class="inline-flex items-center justify-center gap-x-2 rounded-xl border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-2.5 sm:px-4 sm:py-2.5 text-sm font-semibold text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-700 transition-all active:scale-[0.98] sm:rounded-2xl"
:title="$t('identities.import')"
@click="showImportModal = true"
>
<MaterialDesignIcon icon-name="upload" class="w-5 h-5 shrink-0" />
<span class="truncate">{{ $t("identities.import") }}</span>
<span class="hidden sm:inline truncate">{{ $t("identities.import") }}</span>
</button>
<button
type="button"
class="inline-flex w-full items-center justify-center gap-x-2 min-h-[44px] rounded-xl border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-4 py-2.5 text-sm font-semibold text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-700 transition-all active:scale-[0.98] disabled:opacity-50 sm:w-auto sm:min-h-0 sm:rounded-2xl"
class="inline-flex items-center justify-center gap-x-2 rounded-xl border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-2.5 sm:px-4 sm:py-2.5 text-sm font-semibold text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-700 transition-all active:scale-[0.98] disabled:opacity-50 sm:rounded-2xl"
:disabled="identities.length === 0"
:title="$t('identities.export_all')"
@click="downloadAllIdentities"
>
<MaterialDesignIcon icon-name="file-export" class="w-5 h-5 shrink-0" />
<span class="truncate">{{ $t("identities.export_all") }}</span>
<span class="hidden sm:inline truncate">{{ $t("identities.export_all") }}</span>
</button>
</div>
</div>
@@ -90,97 +91,101 @@
identity.is_current,
}"
>
<div class="p-5 flex items-center gap-4">
<!-- icon -->
<div class="relative">
<div
class="w-14 h-14 rounded-2xl flex items-center justify-center shadow-inner overflow-hidden transition-all duration-500"
:class="
identity.is_current && !identity.icon_background_colour
? 'bg-gradient-to-br from-blue-100 to-indigo-100 dark:from-blue-900/50 dark:to-indigo-900/50'
: !identity.icon_background_colour
? 'bg-gradient-to-br from-gray-100 to-slate-100 dark:from-zinc-800 dark:to-zinc-800/50'
: ''
"
:style="
identity.icon_background_colour
? { 'background-color': identity.icon_background_colour }
: {}
"
>
<MaterialDesignIcon
v-if="identity.icon_name"
:icon-name="identity.icon_name"
class="w-8 h-8"
:style="{ color: identity.icon_foreground_colour || 'inherit' }"
/>
<MaterialDesignIcon
v-else
:icon-name="identity.is_current ? 'account-check' : 'account'"
class="w-8 h-8"
<div class="p-4 sm:p-5 flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<div class="flex items-start gap-3 sm:gap-4 sm:flex-1 sm:min-w-0">
<!-- icon -->
<div class="relative shrink-0">
<div
class="w-12 h-12 sm:w-14 sm:h-14 rounded-2xl flex items-center justify-center shadow-inner overflow-hidden transition-all duration-500"
:class="
identity.is_current
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
identity.is_current && !identity.icon_background_colour
? 'bg-gradient-to-br from-blue-100 to-indigo-100 dark:from-blue-900/50 dark:to-indigo-900/50'
: !identity.icon_background_colour
? 'bg-gradient-to-br from-gray-100 to-slate-100 dark:from-zinc-800 dark:to-zinc-800/50'
: ''
"
:style="
identity.icon_background_colour
? { 'background-color': identity.icon_background_colour }
: {}
"
/>
</div>
<div
v-if="identity.is_current"
class="absolute -top-1 -right-1 w-4 h-4 bg-emerald-500 rounded-full border-2 border-white dark:border-zinc-900 shadow-sm"
></div>
</div>
<!-- info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-bold text-gray-900 dark:text-white truncate">
{{ identity.display_name }}
</h3>
<span
v-if="identity.is_current"
class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-[10px] font-bold uppercase tracking-wider"
>
{{ $t("identities.current") }}
</span>
<MaterialDesignIcon
v-if="identity.icon_name"
:icon-name="identity.icon_name"
class="w-7 h-7 sm:w-8 sm:h-8"
:style="{ color: identity.icon_foreground_colour || 'inherit' }"
/>
<MaterialDesignIcon
v-else
:icon-name="identity.is_current ? 'account-check' : 'account'"
class="w-7 h-7 sm:w-8 sm:h-8"
:class="
identity.is_current
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 dark:text-gray-400'
"
/>
</div>
<div
v-if="identity.is_current"
class="absolute -top-1 -right-1 w-4 h-4 bg-emerald-500 rounded-full border-2 border-white dark:border-zinc-900 shadow-sm"
></div>
</div>
<div
class="text-xs font-mono text-gray-500 dark:text-zinc-500 mt-0.5 tracking-tight break-all sm:truncate"
:title="'RNS: ' + identity.hash"
>
ID: {{ identity.hash }}
</div>
<div
v-if="identity.lxmf_address"
class="text-[10px] font-mono text-gray-400 dark:text-zinc-600 mt-0.5 tracking-tighter break-all sm:truncate"
:title="'LXMF: ' + identity.lxmf_address"
>
LXMF: {{ identity.lxmf_address }}
</div>
<div
v-if="identity.lxst_address"
class="text-[10px] font-mono text-gray-400 dark:text-zinc-600 mt-0.5 tracking-tighter break-all sm:truncate"
:title="'LXST: ' + identity.lxst_address"
>
LXST: {{ identity.lxst_address }}
</div>
<div
v-if="identity.message_count != null"
class="text-[10px] text-gray-400 dark:text-zinc-500 mt-0.5"
>
{{ $t("identities.message_count", { count: identity.message_count }) }}
<!-- info -->
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-bold text-gray-900 dark:text-white break-words sm:truncate">
{{ identity.display_name }}
</h3>
<span
v-if="identity.is_current"
class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-[10px] font-bold uppercase tracking-wider"
>
{{ $t("identities.current") }}
</span>
</div>
<div
class="text-xs font-mono text-gray-500 dark:text-zinc-500 mt-0.5 tracking-tight break-all sm:truncate"
:title="'RNS: ' + identity.hash"
>
ID: {{ identity.hash }}
</div>
<div
v-if="identity.lxmf_address"
class="text-[10px] font-mono text-gray-400 dark:text-zinc-600 mt-0.5 tracking-tighter break-all sm:truncate"
:title="'LXMF: ' + identity.lxmf_address"
>
LXMF: {{ identity.lxmf_address }}
</div>
<div
v-if="identity.lxst_address"
class="text-[10px] font-mono text-gray-400 dark:text-zinc-600 mt-0.5 tracking-tighter break-all sm:truncate"
:title="'LXST: ' + identity.lxst_address"
>
LXST: {{ identity.lxst_address }}
</div>
<div
v-if="identity.message_count != null"
class="text-[10px] text-gray-400 dark:text-zinc-500 mt-0.5"
>
{{ $t("identities.message_count", { count: identity.message_count }) }}
</div>
</div>
</div>
<!-- actions: key export on hover for current identity only -->
<div class="flex items-center gap-2">
<!-- actions -->
<div
class="flex items-center justify-end gap-2 border-t border-gray-100 dark:border-zinc-800 pt-3 sm:border-0 sm:pt-0 sm:shrink-0"
>
<template v-if="identity.is_current">
<div
class="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
class="flex items-center gap-1.5 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
>
<button
type="button"
class="p-2.5 rounded-xl bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-300 hover:bg-amber-500 hover:text-white dark:hover:bg-amber-600 transition-all active:scale-90"
class="p-2 sm:p-2.5 rounded-xl bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-300 hover:bg-amber-500 hover:text-white dark:hover:bg-amber-600 transition-all active:scale-90"
:title="$t('identities.export_key_file')"
@click="downloadIdentityFile"
>
@@ -188,7 +193,7 @@
</button>
<button
type="button"
class="p-2.5 rounded-xl bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-300 hover:bg-amber-500 hover:text-white dark:hover:bg-amber-600 transition-all active:scale-90"
class="p-2 sm:p-2.5 rounded-xl bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-300 hover:bg-amber-500 hover:text-white dark:hover:bg-amber-600 transition-all active:scale-90"
:title="$t('identities.copy_base32')"
@click="copyIdentityBase32"
>
@@ -199,7 +204,7 @@
<button
v-if="!identity.is_current"
type="button"
class="p-2.5 rounded-xl bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-300 hover:bg-blue-500 hover:text-white dark:hover:bg-blue-600 transition-all active:scale-90"
class="p-2 sm:p-2.5 rounded-xl bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-300 hover:bg-blue-500 hover:text-white dark:hover:bg-blue-600 transition-all active:scale-90"
:title="$t('identities.switch')"
@click="switchIdentity(identity)"
>
@@ -208,7 +213,7 @@
<button
v-if="!identity.is_current"
type="button"
class="p-2.5 rounded-xl bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-300 hover:bg-red-500 hover:text-white dark:hover:bg-red-600 transition-all active:scale-90"
class="p-2 sm:p-2.5 rounded-xl bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-300 hover:bg-red-500 hover:text-white dark:hover:bg-red-600 transition-all active:scale-90"
:title="$t('identities.delete')"
@click="deleteIdentity(identity)"
>

View File

@@ -330,6 +330,70 @@
@change="importStickers"
/>
</div>
<div class="border-t border-gray-200 dark:border-zinc-700 pt-4">
<h3 class="text-sm font-semibold mb-2 text-gray-800 dark:text-zinc-100">
{{ $t("sticker_packs.section_title") }}
</h3>
<p class="text-xs text-gray-500 dark:text-zinc-400 mb-3">
{{ $t("sticker_packs.section_description") }}
</p>
<StickerPacksManager />
</div>
</div>
</section>
<section
v-show="matchesSearch(...sectionKeywords.gifs)"
class="settings-section break-inside-avoid"
>
<header class="settings-section__header">
<div>
<div class="settings-section__eyebrow">Messages</div>
<h2>{{ $t("gifs.settings_title") }}</h2>
<p>{{ $t("gifs.settings_description") }}</p>
</div>
</header>
<div class="settings-section__body space-y-4">
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ $t("gifs.count", { count: gifCount }) }}
</div>
<label
class="flex items-center gap-2 text-sm text-gray-800 dark:text-gray-200 cursor-pointer"
>
<input v-model="gifImportReplaceDuplicates" type="checkbox" class="rounded" />
{{ $t("gifs.replace_duplicates") }}
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-amber-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-amber-500 transition group"
@click="exportGifs"
>
<MaterialDesignIcon
icon-name="export"
class="size-6 text-amber-500 group-hover:scale-110 transition"
/>
<div class="text-sm font-bold">{{ $t("gifs.export") }}</div>
</button>
<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="triggerGifImport"
>
<MaterialDesignIcon
icon-name="import"
class="size-6 text-teal-500 group-hover:scale-110 transition"
/>
<div class="text-sm font-bold">{{ $t("gifs.import") }}</div>
</button>
<input
ref="gifImportFile"
type="file"
accept=".json,application/json"
class="hidden"
@change="importGifs"
/>
</div>
</div>
</section>
@@ -427,6 +491,22 @@
</div>
</button>
<button
type="button"
class="btn-maintenance border-pink-200 dark:border-pink-900/30 text-pink-700 dark:text-pink-300 bg-pink-50 dark:bg-pink-900/10 hover:bg-pink-100 dark:hover:bg-pink-900/20"
@click="clearGifs"
>
<div class="flex flex-col items-start text-left">
<div class="font-bold flex items-center gap-2">
<MaterialDesignIcon icon-name="file-gif-box" class="size-4" />
{{ $t("maintenance.clear_gifs") }}
</div>
<div class="text-xs opacity-80">
{{ $t("maintenance.clear_gifs_desc") }}
</div>
</div>
</button>
<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"
@@ -2022,16 +2102,44 @@
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
Delivery transfer limit (MB)
{{ $t("app.incoming_message_size") }}
</div>
<input
v-model.number="lxmfDeliveryTransferLimitInputMb"
type="number"
min="0.001"
step="0.01"
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("app.incoming_message_size_description") }}
</div>
<select
v-model="lxmfIncomingDeliveryPreset"
class="input-field"
@input="onLxmfDeliveryTransferLimitChange"
/>
@change="onLxmfIncomingDeliveryPresetChange"
>
<option value="1mb">{{ $t("app.incoming_message_size_1mb") }}</option>
<option value="10mb">{{ $t("app.incoming_message_size_10mb") }}</option>
<option value="25mb">{{ $t("app.incoming_message_size_25mb") }}</option>
<option value="50mb">{{ $t("app.incoming_message_size_50mb") }}</option>
<option value="1gb">{{ $t("app.incoming_message_size_1gb") }}</option>
<option value="custom">{{ $t("app.incoming_message_size_custom") }}</option>
</select>
<div
v-if="lxmfIncomingDeliveryPreset === 'custom'"
class="flex flex-wrap items-center gap-2"
>
<input
v-model.number="lxmfIncomingDeliveryCustomAmount"
type="number"
min="0.001"
step="any"
class="input-field max-w-[10rem]"
@input="onLxmfIncomingDeliveryCustomChange"
/>
<select
v-model="lxmfIncomingDeliveryCustomUnit"
class="input-field max-w-[8rem]"
@change="onLxmfIncomingDeliveryCustomChange"
>
<option value="mb">{{ $t("app.incoming_message_size_unit_mb") }}</option>
<option value="gb">{{ $t("app.incoming_message_size_unit_gb") }}</option>
</select>
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ formatByteSize(config.lxmf_delivery_transfer_limit_in_bytes) }}
</div>
@@ -2185,6 +2293,7 @@ import SettingsSectionBlock from "./SettingsSectionBlock.vue";
import KeyboardShortcuts from "../../js/KeyboardShortcuts";
import ElectronUtils from "../../js/ElectronUtils";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import StickerPacksManager from "../stickers/StickerPacksManager.vue";
import GlobalState from "../../js/GlobalState";
import {
numOrNull,
@@ -2199,6 +2308,11 @@ import {
persistVisualiserShowDisabled,
persistVisualiserShowDiscovered,
} from "../../js/settings/settingsVisualiserPrefs";
import {
incomingDeliveryBytesFromCustom,
incomingDeliveryBytesFromPresetKey,
syncIncomingDeliveryFieldsFromBytes,
} from "../../js/settings/incomingDeliveryLimit";
export default {
name: "SettingsPage",
@@ -2208,6 +2322,7 @@ export default {
ShortcutRecorder,
LxmfUserIcon,
SettingsSectionBlock,
StickerPacksManager,
},
data() {
return {
@@ -2277,7 +2392,9 @@ export default {
nomad_default_page_path: "/page/index.mu",
},
saveTimeouts: {},
lxmfDeliveryTransferLimitInputMb: 10,
lxmfIncomingDeliveryPreset: "10mb",
lxmfIncomingDeliveryCustomAmount: 10,
lxmfIncomingDeliveryCustomUnit: "mb",
lxmfPropagationTransferLimitInputMb: 0.256,
lxmfPropagationSyncLimitInputMb: 10.24,
lastRememberedInboundStampCost: 8,
@@ -2288,6 +2405,8 @@ export default {
trustedTelemetryPeers: [],
stickerCount: 0,
stickerImportReplaceDuplicates: false,
gifCount: 0,
gifImportReplaceDuplicates: false,
visualiserShowDisabledInterfaces: false,
visualiserShowDiscoveredInterfaces: false,
sectionKeywords: {
@@ -2320,6 +2439,18 @@ export default {
"stickers.export",
"stickers.import",
"stickers.replace_duplicates",
"sticker_packs.section_title",
"sticker_packs.create",
"sticker_packs.install_from_file",
"sticker_packs.open_editor",
],
gifs: [
"GIFs",
"gifs.settings_title",
"gifs.settings_description",
"gifs.export",
"gifs.import",
"gifs.replace_duplicates",
],
maintenance: [
"Maintenance",
@@ -2335,6 +2466,8 @@ export default {
"maintenance.clear_lxmf_icons_desc",
"maintenance.clear_stickers",
"maintenance.clear_stickers_desc",
"maintenance.clear_gifs",
"maintenance.clear_gifs_desc",
"maintenance.clear_archives",
"maintenance.clear_archives_desc",
"maintenance.clear_reticulum_docs",
@@ -2401,7 +2534,19 @@ export default {
"app.live_preview",
"app.realtime",
],
language: ["i18n", "app.language", "app.select_language", "English", "Deutsch", "Русский"],
language: [
"i18n",
"app.language",
"app.select_language",
"English",
"Deutsch",
"Italiano",
"Русский",
"Nederlands",
"Français",
"Español",
"中文",
],
networkSecurity: [
"RNS Security",
"Network Security",
@@ -2458,6 +2603,8 @@ export default {
],
propagation: [
"LXMF",
"app.incoming_message_size",
"app.incoming_message_size_description",
"app.propagation_nodes",
"app.propagation_nodes_description",
"app.browse_nodes",
@@ -2538,6 +2685,7 @@ export default {
this.getConfig();
this.getTrustedTelemetryPeers();
this.loadStickerCount();
this.loadGifCount();
this.loadVisualiserDisplayPrefsFromStorage();
},
methods: {
@@ -2672,7 +2820,10 @@ export default {
}
},
syncLxmfTransferLimitInputs() {
this.lxmfDeliveryTransferLimitInputMb = this.bytesToMb(this.config.lxmf_delivery_transfer_limit_in_bytes);
const incoming = syncIncomingDeliveryFieldsFromBytes(this.config.lxmf_delivery_transfer_limit_in_bytes);
this.lxmfIncomingDeliveryPreset = incoming.preset;
this.lxmfIncomingDeliveryCustomAmount = incoming.customAmount;
this.lxmfIncomingDeliveryCustomUnit = incoming.customUnit;
this.lxmfPropagationTransferLimitInputMb = this.bytesToMb(
this.config.lxmf_propagation_transfer_limit_in_bytes
);
@@ -2955,13 +3106,37 @@ export default {
"auto_sync"
);
},
async onLxmfDeliveryTransferLimitChange() {
async onLxmfIncomingDeliveryPresetChange() {
if (this.lxmfIncomingDeliveryPreset === "custom") {
const incoming = syncIncomingDeliveryFieldsFromBytes(this.config.lxmf_delivery_transfer_limit_in_bytes);
this.lxmfIncomingDeliveryCustomAmount = incoming.customAmount;
this.lxmfIncomingDeliveryCustomUnit = incoming.customUnit;
return;
}
const bytes = incomingDeliveryBytesFromPresetKey(this.lxmfIncomingDeliveryPreset);
if (bytes == null) {
return;
}
await this.updateConfig(
{
lxmf_delivery_transfer_limit_in_bytes: bytes,
},
"incoming_message_size"
);
},
async onLxmfIncomingDeliveryCustomChange() {
if (this.lxmfIncomingDeliveryPreset !== "custom") {
return;
}
if (this.saveTimeouts.delivery_transfer_limit) {
clearTimeout(this.saveTimeouts.delivery_transfer_limit);
}
this.saveTimeouts.delivery_transfer_limit = setTimeout(async () => {
await this.updateConfig({
lxmf_delivery_transfer_limit_in_bytes: this.mbToBytes(this.lxmfDeliveryTransferLimitInputMb),
lxmf_delivery_transfer_limit_in_bytes: incomingDeliveryBytesFromCustom(
this.lxmfIncomingDeliveryCustomAmount,
this.lxmfIncomingDeliveryCustomUnit
),
});
}, 1000);
},
@@ -3389,6 +3564,64 @@ export default {
reader.readAsText(file);
event.target.value = "";
},
async clearGifs() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
await maintenanceClient.clearGifs(window.api);
ToastUtils.success(this.$t("maintenance.gifs_cleared"));
await this.loadGifCount();
} catch {
ToastUtils.error(this.$t("common.error"));
}
},
async loadGifCount() {
this.gifCount = await maintenanceClient.fetchGifCount(window.api);
},
async exportGifs() {
try {
const response = await window.api.get("/api/v1/gifs/export");
const dataStr = JSON.stringify(response.data, null, 2);
const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
const exportFileDefaultName = `meshchat_gifs_${new Date().toISOString().slice(0, 10)}.json`;
const linkElement = document.createElement("a");
linkElement.setAttribute("href", dataUri);
linkElement.setAttribute("download", exportFileDefaultName);
linkElement.click();
ToastUtils.success(this.$t("gifs.export_done"));
} catch {
ToastUtils.error(this.$t("gifs.import_failed"));
}
},
triggerGifImport() {
this.$refs.gifImportFile.click();
},
async importGifs(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const data = JSON.parse(e.target.result);
const response = await window.api.post("/api/v1/gifs/import", {
...data,
replace_duplicates: this.gifImportReplaceDuplicates,
});
const r = response.data;
ToastUtils.success(
this.$t("gifs.import_success", {
imported: r.imported ?? 0,
skipped_duplicates: r.skipped_duplicates ?? 0,
skipped_invalid: r.skipped_invalid ?? 0,
})
);
await this.loadGifCount();
} catch {
ToastUtils.error(this.$t("gifs.import_failed"));
}
};
reader.readAsText(file);
event.target.value = "";
},
async clearArchives() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
@@ -3523,22 +3756,25 @@ export default {
@apply w-full px-4 py-3 rounded-2xl border transition flex items-center justify-between;
}
.setting-toggle {
@apply flex items-start gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 px-3 py-3;
@apply relative flex flex-row-reverse items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 px-3 py-3;
}
.setting-toggle > :deep(label) {
@apply shrink-0 self-center;
}
.setting-toggle :deep(.sr-only) {
@apply absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0;
}
.setting-toggle__label {
@apply flex-1 flex flex-col gap-0.5;
@apply flex-1 min-w-0 flex flex-col gap-0.5;
}
.setting-toggle__title {
@apply text-sm font-semibold text-gray-900 dark:text-white;
@apply text-sm font-semibold text-gray-900 dark:text-white break-words;
}
.setting-toggle__description {
@apply text-sm text-gray-600 dark:text-gray-300;
@apply text-sm text-gray-600 dark:text-gray-300 break-words;
}
.setting-toggle__hint {
@apply text-xs text-gray-500 dark:text-gray-400;
@apply text-xs text-gray-500 dark:text-gray-400 break-words;
}
.info-callout {
@apply rounded-2xl border border-blue-100 dark:border-blue-900/40 bg-blue-50/60 dark:bg-blue-900/20 px-3 py-3 text-blue-900 dark:text-blue-100;

View File

@@ -0,0 +1,625 @@
<template>
<div
v-if="visible"
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/70 p-4"
@click.self="onCancel"
>
<div
class="w-full max-w-3xl max-h-[92vh] overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl flex flex-col border border-gray-200 dark:border-zinc-700"
>
<header class="flex items-center justify-between border-b border-gray-200 dark:border-zinc-700 px-4 py-3">
<div class="flex items-center gap-2">
<MaterialDesignIcon icon-name="sticker-emoji" class="size-5 text-blue-500" />
<h2 class="text-lg font-semibold text-gray-800 dark:text-zinc-100">
{{ $t("sticker_editor.title") }}
</h2>
</div>
<button
type="button"
class="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800"
@click="onCancel"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</header>
<div class="flex-1 overflow-y-auto p-4 grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4">
<div class="flex flex-col items-center gap-3">
<div
class="relative w-full max-w-[512px] aspect-square rounded-xl bg-checkerboard border border-gray-200 dark:border-zinc-700 overflow-hidden"
>
<canvas ref="canvas" class="w-full h-full" :width="canvasSize" :height="canvasSize" />
<div
v-if="busy"
class="absolute inset-0 flex items-center justify-center bg-black/40 text-white text-sm"
>
<div class="flex flex-col items-center gap-2">
<div class="size-8 border-4 border-white/50 border-t-white rounded-full animate-spin" />
<span>{{ busyMessage || $t("sticker_editor.processing") }}</span>
</div>
</div>
</div>
<div class="text-xs text-gray-500 dark:text-zinc-400 text-center max-w-md px-2">
{{ $t("sticker_editor.canvas_hint") }}
</div>
</div>
<div class="flex flex-col gap-3 text-sm">
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold uppercase text-gray-500 dark:text-zinc-400">
{{ $t("sticker_editor.section_source") }}
</label>
<button
type="button"
class="rounded-xl border border-gray-300 dark:border-zinc-600 px-3 py-2 hover:border-blue-500 text-gray-700 dark:text-zinc-200 flex items-center gap-2"
@click="triggerSourceInput"
>
<MaterialDesignIcon icon-name="image-plus" class="size-4" />
{{ $t("sticker_editor.choose_image") }}
</button>
<input
ref="sourceInput"
type="file"
accept="image/png,image/jpeg,image/webp,image/bmp,image/gif"
class="hidden"
@change="onSourceFile"
/>
</div>
<div v-if="sourceLoaded" class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold uppercase text-gray-500 dark:text-zinc-400">
{{ $t("sticker_editor.section_transform") }}
</label>
<label class="flex items-center justify-between gap-2">
<span>{{ $t("sticker_editor.scale") }}</span>
<input
v-model.number="scale"
type="range"
min="0.1"
max="3"
step="0.01"
class="flex-1 mx-2"
@input="redraw"
/>
<span class="w-10 text-right tabular-nums">{{ scale.toFixed(2) }}</span>
</label>
<label class="flex items-center justify-between gap-2">
<span>{{ $t("sticker_editor.rotation") }}</span>
<input
v-model.number="rotation"
type="range"
min="-180"
max="180"
step="1"
class="flex-1 mx-2"
@input="redraw"
/>
<span class="w-10 text-right tabular-nums">{{ rotation }}&deg;</span>
</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex items-center gap-2">
<input v-model="flipH" type="checkbox" @change="redraw" />
{{ $t("sticker_editor.flip_h") }}
</label>
<label class="flex items-center gap-2">
<input v-model="flipV" type="checkbox" @change="redraw" />
{{ $t("sticker_editor.flip_v") }}
</label>
</div>
<button
type="button"
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1 text-xs"
@click="resetTransform"
>
{{ $t("sticker_editor.reset_transform") }}
</button>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold uppercase text-gray-500 dark:text-zinc-400">
{{ $t("sticker_editor.section_effects") }}
</label>
<button
type="button"
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1 hover:border-emerald-500"
:disabled="busy"
@click="removeBackground"
>
{{
bgRemoved ? $t("sticker_editor.bg_removed") : $t("sticker_editor.remove_background")
}}
</button>
<label class="flex items-center justify-between gap-2">
<span>{{ $t("sticker_editor.white_stroke") }}</span>
<input
v-model.number="strokeWidth"
type="range"
min="0"
max="24"
step="1"
class="flex-1 mx-2"
@input="redraw"
/>
<span class="w-10 text-right tabular-nums">{{ strokeWidth }}px</span>
</label>
<label class="flex items-center justify-between gap-2">
<span>{{ $t("sticker_editor.shadow") }}</span>
<input
v-model.number="shadowBlur"
type="range"
min="0"
max="48"
step="1"
class="flex-1 mx-2"
@input="redraw"
/>
<span class="w-10 text-right tabular-nums">{{ shadowBlur }}px</span>
</label>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold uppercase text-gray-500 dark:text-zinc-400">
{{ $t("sticker_editor.section_overlay") }}
</label>
<input
v-model="overlayText"
type="text"
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1 bg-white dark:bg-zinc-800"
:placeholder="$t('sticker_editor.overlay_placeholder')"
@input="redraw"
/>
<label class="flex items-center justify-between gap-2">
<span>{{ $t("sticker_editor.font_size") }}</span>
<input
v-model.number="overlayFontSize"
type="range"
min="16"
max="160"
step="2"
class="flex-1 mx-2"
@input="redraw"
/>
<span class="w-10 text-right tabular-nums">{{ overlayFontSize }}</span>
</label>
<label class="flex items-center justify-between gap-2">
<span>{{ $t("sticker_editor.overlay_y") }}</span>
<input
v-model.number="overlayY"
type="range"
min="0"
max="100"
step="1"
class="flex-1 mx-2"
@input="redraw"
/>
<span class="w-10 text-right tabular-nums">{{ overlayY }}%</span>
</label>
<div class="flex items-center gap-2">
<label class="flex items-center gap-1">
<input v-model="overlayColor" type="color" class="w-7 h-7 cursor-pointer" />
{{ $t("sticker_editor.text_color") }}
</label>
<label class="flex items-center gap-1">
<input v-model="overlayStrokeColor" type="color" class="w-7 h-7 cursor-pointer" />
{{ $t("sticker_editor.stroke_color") }}
</label>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold uppercase text-gray-500 dark:text-zinc-400">
{{ $t("sticker_editor.section_meta") }}
</label>
<input
v-model="stickerName"
type="text"
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1 bg-white dark:bg-zinc-800"
:placeholder="$t('sticker_editor.name_placeholder')"
maxlength="64"
/>
<input
v-model="stickerEmoji"
type="text"
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1 bg-white dark:bg-zinc-800"
:placeholder="$t('sticker_editor.emoji_placeholder')"
maxlength="8"
/>
<label class="flex items-center justify-between gap-2">
<span>{{ $t("sticker_editor.format") }}</span>
<select
v-model="exportFormat"
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1 bg-white dark:bg-zinc-800"
>
<option value="webp">WebP</option>
<option value="png">PNG</option>
</select>
</label>
<label class="flex items-center justify-between gap-2">
<span>{{ $t("sticker_editor.quality") }}</span>
<input
v-model.number="exportQuality"
type="range"
min="0.5"
max="1"
step="0.01"
class="flex-1 mx-2"
/>
<span class="w-10 text-right tabular-nums">{{ Math.round(exportQuality * 100) }}%</span>
</label>
</div>
</div>
</div>
</div>
<footer
class="flex items-center justify-between gap-2 border-t border-gray-200 dark:border-zinc-700 px-4 py-3 bg-gray-50 dark:bg-zinc-900/50"
>
<div class="text-xs text-gray-500 dark:text-zinc-400">
{{
$t("sticker_editor.size_label", {
size: formattedSize,
limit: "512 KB",
})
}}
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-zinc-800"
@click="onCancel"
>
{{ $t("sticker_editor.cancel") }}
</button>
<button
type="button"
class="rounded-lg bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 py-1.5 text-sm font-medium"
:disabled="!sourceLoaded || busy || !canSave"
@click="onSave"
>
{{ $t("sticker_editor.save") }}
</button>
</div>
</footer>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils.js";
const CANVAS_SIZE = 512;
const TELEGRAM_STATIC_LIMIT = 512 * 1024;
export default {
name: "StickerEditor",
components: { MaterialDesignIcon },
props: {
visible: { type: Boolean, default: false },
defaultPackId: { type: [Number, String, null], default: null },
initialFile: { type: [File, Blob, null], default: null },
},
emits: ["close", "saved"],
data() {
return {
canvasSize: CANVAS_SIZE,
sourceImage: null,
sourceLoaded: false,
scale: 1,
rotation: 0,
flipH: false,
flipV: false,
strokeWidth: 0,
shadowBlur: 0,
overlayText: "",
overlayFontSize: 64,
overlayY: 90,
overlayColor: "#ffffff",
overlayStrokeColor: "#000000",
stickerName: "",
stickerEmoji: "",
exportFormat: "webp",
exportQuality: 0.92,
busy: false,
busyMessage: "",
bgRemoved: false,
lastBlob: null,
bgRemovalModule: null,
};
},
computed: {
formattedSize() {
const size = this.lastBlob ? this.lastBlob.size : 0;
if (size <= 0) return "0 KB";
return `${(size / 1024).toFixed(1)} KB`;
},
canSave() {
return this.lastBlob && this.lastBlob.size > 0 && this.lastBlob.size <= TELEGRAM_STATIC_LIMIT;
},
},
watch: {
visible(v) {
if (v) {
this.resetState();
this.$nextTick(() => {
if (this.initialFile) {
this.loadSourceFromBlob(this.initialFile);
}
});
}
},
exportFormat() {
this.redraw();
},
exportQuality() {
this.redraw();
},
},
methods: {
resetState() {
this.sourceImage = null;
this.sourceLoaded = false;
this.scale = 1;
this.rotation = 0;
this.flipH = false;
this.flipV = false;
this.strokeWidth = 0;
this.shadowBlur = 0;
this.overlayText = "";
this.stickerName = "";
this.stickerEmoji = "";
this.bgRemoved = false;
this.lastBlob = null;
const c = this.$refs.canvas;
if (c) {
const ctx = c.getContext("2d");
ctx.clearRect(0, 0, c.width, c.height);
}
},
resetTransform() {
this.scale = 1;
this.rotation = 0;
this.flipH = false;
this.flipV = false;
this.redraw();
},
triggerSourceInput() {
this.$refs.sourceInput?.click();
},
onSourceFile(event) {
const file = event.target.files?.[0];
if (file) this.loadSourceFromBlob(file);
event.target.value = "";
},
async loadSourceFromBlob(blob) {
const url = URL.createObjectURL(blob);
try {
const img = await this.loadImage(url);
this.sourceImage = img;
this.bgRemoved = false;
const longest = Math.max(img.naturalWidth, img.naturalHeight) || 1;
this.scale = Math.min(1, CANVAS_SIZE / longest);
this.sourceLoaded = true;
this.redraw();
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("sticker_editor.image_load_failed"));
} finally {
URL.revokeObjectURL(url);
}
},
loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
},
async redraw() {
if (!this.sourceImage) return;
const canvas = this.$refs.canvas;
if (!canvas) return;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
const img = this.sourceImage;
const drawW = img.naturalWidth * this.scale;
const drawH = img.naturalHeight * this.scale;
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((this.rotation * Math.PI) / 180);
ctx.scale(this.flipH ? -1 : 1, this.flipV ? -1 : 1);
if (this.shadowBlur > 0) {
ctx.shadowColor = "rgba(0,0,0,0.55)";
ctx.shadowBlur = this.shadowBlur;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = Math.max(2, this.shadowBlur / 4);
}
if (this.strokeWidth > 0 && this.bgRemoved) {
this.drawStrokedImage(ctx, img, drawW, drawH, this.strokeWidth);
} else {
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
}
ctx.restore();
if (this.overlayText) {
this.drawOverlayText(ctx);
}
await this.recomputeBlob();
},
drawStrokedImage(ctx, img, w, h, stroke) {
const off = document.createElement("canvas");
off.width = w + stroke * 2;
off.height = h + stroke * 2;
const oc = off.getContext("2d");
oc.drawImage(img, stroke, stroke, w, h);
const stamp = document.createElement("canvas");
stamp.width = off.width;
stamp.height = off.height;
const sc = stamp.getContext("2d");
for (let dx = -stroke; dx <= stroke; dx += 1) {
for (let dy = -stroke; dy <= stroke; dy += 1) {
if (dx * dx + dy * dy > stroke * stroke) continue;
sc.drawImage(off, dx, dy);
}
}
sc.globalCompositeOperation = "source-in";
sc.fillStyle = "#ffffff";
sc.fillRect(0, 0, stamp.width, stamp.height);
ctx.drawImage(stamp, -stamp.width / 2, -stamp.height / 2);
ctx.drawImage(img, -w / 2, -h / 2, w, h);
},
drawOverlayText(ctx) {
const text = this.overlayText;
const px = Math.max(8, this.overlayFontSize);
ctx.save();
ctx.font = `900 ${px}px "Inter", "Helvetica Neue", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const cy = (this.overlayY / 100) * this.canvasSize;
ctx.lineJoin = "round";
ctx.miterLimit = 2;
ctx.lineWidth = Math.max(2, px / 8);
ctx.strokeStyle = this.overlayStrokeColor;
ctx.strokeText(text, this.canvasSize / 2, cy);
ctx.fillStyle = this.overlayColor;
ctx.fillText(text, this.canvasSize / 2, cy);
ctx.restore();
},
async recomputeBlob() {
const canvas = this.$refs.canvas;
if (!canvas) {
this.lastBlob = null;
return;
}
const mime = this.exportFormat === "png" ? "image/png" : "image/webp";
const quality = this.exportFormat === "png" ? undefined : this.exportQuality;
const blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b), mime, quality));
this.lastBlob = blob;
},
async removeBackground() {
if (!this.sourceImage) return;
this.busy = true;
this.busyMessage = this.$t("sticker_editor.removing_background");
try {
if (!this.bgRemovalModule) {
this.bgRemovalModule = await import("@imgly/background-removal");
}
const removeFn = this.bgRemovalModule.removeBackground || this.bgRemovalModule.default;
if (!removeFn) {
throw new Error("background removal entrypoint not found");
}
const blob = await this.blobFromSourceImage();
const cleaned = await removeFn(blob, {
output: { format: "image/png", quality: 0.95 },
});
const url = URL.createObjectURL(cleaned);
try {
const img = await this.loadImage(url);
this.sourceImage = img;
this.bgRemoved = true;
if (this.strokeWidth === 0) {
this.strokeWidth = 8;
}
if (this.shadowBlur === 0) {
this.shadowBlur = 16;
}
await this.redraw();
} finally {
URL.revokeObjectURL(url);
}
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("sticker_editor.bg_removal_failed"));
} finally {
this.busy = false;
this.busyMessage = "";
}
},
async blobFromSourceImage() {
const c = document.createElement("canvas");
c.width = this.sourceImage.naturalWidth;
c.height = this.sourceImage.naturalHeight;
const ctx = c.getContext("2d");
ctx.drawImage(this.sourceImage, 0, 0);
return new Promise((resolve) => c.toBlob((b) => resolve(b), "image/png"));
},
arrayBufferToBase64(buf) {
const bytes = new Uint8Array(buf);
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return btoa(binary);
},
async onSave() {
if (!this.lastBlob) return;
if (this.lastBlob.size > TELEGRAM_STATIC_LIMIT) {
ToastUtils.error(this.$t("sticker_editor.too_large"));
return;
}
this.busy = true;
this.busyMessage = this.$t("sticker_editor.saving");
try {
const buf = await this.lastBlob.arrayBuffer();
const b64 = this.arrayBufferToBase64(buf);
const payload = {
image_bytes: b64,
image_type: this.exportFormat,
name: this.stickerName || null,
emoji: this.stickerEmoji || null,
strict: true,
};
if (this.defaultPackId != null) {
payload.pack_id = Number(this.defaultPackId);
}
const r = await window.api.post("/api/v1/stickers", payload);
ToastUtils.success(this.$t("sticker_editor.saved"));
this.$emit("saved", r.data?.sticker || null);
this.$emit("close");
} catch (e) {
const err = e?.response?.data?.error || "save_failed";
if (err === "duplicate_sticker") {
ToastUtils.info(this.$t("stickers.duplicate"));
} else {
ToastUtils.error(`${this.$t("sticker_editor.save_failed")}: ${err}`);
}
} finally {
this.busy = false;
this.busyMessage = "";
}
},
onCancel() {
this.$emit("close");
},
},
};
</script>
<style scoped>
.bg-checkerboard {
background-image:
linear-gradient(45deg, #d1d5db 25%, transparent 25%), linear-gradient(-45deg, #d1d5db 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #d1d5db 75%), linear-gradient(-45deg, transparent 75%, #d1d5db 75%);
background-size: 20px 20px;
background-position:
0 0,
0 10px,
10px -10px,
-10px 0;
background-color: #f3f4f6;
}
:global(.dark) .bg-checkerboard {
background-image:
linear-gradient(45deg, #374151 25%, transparent 25%), linear-gradient(-45deg, #374151 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #374151 75%), linear-gradient(-45deg, transparent 75%, #374151 75%);
background-color: #1f2937;
}
</style>

View File

@@ -0,0 +1,317 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 text-sm font-medium flex items-center gap-1"
@click="openCreatePack"
>
<MaterialDesignIcon icon-name="folder-plus-outline" class="size-4" />
{{ $t("sticker_packs.create") }}
</button>
<button
type="button"
class="rounded-xl border border-gray-300 dark:border-zinc-600 px-3 py-1.5 text-sm hover:border-emerald-500 flex items-center gap-1"
@click="openEditor()"
>
<MaterialDesignIcon icon-name="image-edit-outline" class="size-4" />
{{ $t("sticker_packs.open_editor") }}
</button>
<button
type="button"
class="rounded-xl border border-gray-300 dark:border-zinc-600 px-3 py-1.5 text-sm hover:border-teal-500 flex items-center gap-1"
@click="triggerInstallInput"
>
<MaterialDesignIcon icon-name="package-down" class="size-4" />
{{ $t("sticker_packs.install_from_file") }}
</button>
<input
ref="installFileInput"
type="file"
accept=".json,application/json"
class="hidden"
@change="onInstallFile"
/>
</div>
<div v-if="packs.length === 0" class="text-sm text-gray-500 dark:text-zinc-400">
{{ $t("sticker_packs.empty") }}
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div
v-for="pack in packs"
:key="pack.id"
class="rounded-xl border border-gray-200 dark:border-zinc-700 p-3 bg-white/60 dark:bg-zinc-800/60 flex flex-col gap-2"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold text-gray-800 dark:text-zinc-100 truncate">
{{ pack.title }}
</div>
<div class="text-xs text-gray-500 dark:text-zinc-400">
{{
$t("sticker_packs.count_label", {
count: pack.sticker_count,
})
}}
&middot;
{{ $t(`sticker_packs.type_${pack.pack_type}`) }}
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="rounded-lg p-1.5 hover:bg-gray-100 dark:hover:bg-zinc-700 text-gray-600 dark:text-zinc-300"
:title="$t('sticker_packs.add_sticker')"
@click="openEditor(pack.id)"
>
<MaterialDesignIcon icon-name="plus" class="size-4" />
</button>
<button
type="button"
class="rounded-lg p-1.5 hover:bg-gray-100 dark:hover:bg-zinc-700 text-gray-600 dark:text-zinc-300"
:title="$t('sticker_packs.export')"
@click="exportPack(pack)"
>
<MaterialDesignIcon icon-name="export" class="size-4" />
</button>
<button
type="button"
class="rounded-lg p-1.5 hover:bg-red-50 dark:hover:bg-red-950/30 text-red-600"
:title="$t('sticker_packs.delete')"
@click="deletePack(pack)"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
</button>
</div>
</div>
<div v-if="pack.stickers && pack.stickers.length > 0" class="grid grid-cols-6 gap-1.5 mt-1">
<StickerView
v-for="s in pack.stickers.slice(0, 12)"
:key="s.id"
:src="stickerImageUrl(s.id)"
:image-type="s.image_type"
size="xs"
class="rounded border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-900"
/>
</div>
<div v-else class="text-xs text-gray-500 dark:text-zinc-400 italic mt-1">
{{ $t("sticker_packs.empty_pack") }}
</div>
</div>
</div>
<div
v-if="createOpen"
class="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-4"
@click.self="createOpen = false"
>
<div
class="w-full max-w-md rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl border border-gray-200 dark:border-zinc-700"
>
<header class="px-4 py-3 border-b border-gray-200 dark:border-zinc-700 font-semibold">
{{ $t("sticker_packs.create_title") }}
</header>
<div class="p-4 flex flex-col gap-3">
<input
v-model="newPackTitle"
type="text"
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1.5 bg-white dark:bg-zinc-800"
:placeholder="$t('sticker_packs.field_title')"
maxlength="80"
/>
<input
v-model="newPackShortName"
type="text"
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1.5 bg-white dark:bg-zinc-800"
:placeholder="$t('sticker_packs.field_short_name')"
maxlength="32"
/>
<textarea
v-model="newPackDescription"
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1.5 bg-white dark:bg-zinc-800"
:placeholder="$t('sticker_packs.field_description')"
rows="2"
maxlength="280"
/>
<select
v-model="newPackType"
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-2 py-1.5 bg-white dark:bg-zinc-800"
>
<option value="static">{{ $t("sticker_packs.type_static") }}</option>
<option value="animated">{{ $t("sticker_packs.type_animated") }}</option>
<option value="video">{{ $t("sticker_packs.type_video") }}</option>
<option value="mixed">{{ $t("sticker_packs.type_mixed") }}</option>
</select>
<label class="flex items-center gap-2 text-sm">
<input v-model="newPackStrict" type="checkbox" />
{{ $t("sticker_packs.strict_label") }}
</label>
</div>
<footer
class="flex items-center justify-end gap-2 px-4 py-3 border-t border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-900/50"
>
<button
type="button"
class="rounded-lg border border-gray-300 dark:border-zinc-600 px-3 py-1.5 text-sm"
@click="createOpen = false"
>
{{ $t("sticker_editor.cancel") }}
</button>
<button
type="button"
class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 text-sm"
:disabled="!newPackTitle"
@click="confirmCreatePack"
>
{{ $t("sticker_packs.create") }}
</button>
</footer>
</div>
</div>
<StickerEditor
:visible="editorOpen"
:default-pack-id="editorPackId"
@close="onEditorClose"
@saved="onEditorSaved"
/>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import StickerEditor from "./StickerEditor.vue";
import StickerView from "./StickerView.vue";
import ToastUtils from "../../js/ToastUtils.js";
import DialogUtils from "../../js/DialogUtils.js";
export default {
name: "StickerPacksManager",
components: { MaterialDesignIcon, StickerEditor, StickerView },
data() {
return {
packs: [],
createOpen: false,
newPackTitle: "",
newPackShortName: "",
newPackDescription: "",
newPackType: "mixed",
newPackStrict: true,
editorOpen: false,
editorPackId: null,
};
},
mounted() {
this.loadPacks();
},
methods: {
async loadPacks() {
try {
const r = await window.api.get("/api/v1/sticker-packs");
this.packs = r.data?.packs || [];
} catch (e) {
console.error(e);
this.packs = [];
}
},
stickerImageUrl(id) {
return `/api/v1/stickers/${id}/image`;
},
openCreatePack() {
this.newPackTitle = "";
this.newPackShortName = "";
this.newPackDescription = "";
this.newPackType = "mixed";
this.newPackStrict = true;
this.createOpen = true;
},
async confirmCreatePack() {
try {
await window.api.post("/api/v1/sticker-packs", {
title: this.newPackTitle,
short_name: this.newPackShortName || null,
description: this.newPackDescription || null,
pack_type: this.newPackType,
is_strict: this.newPackStrict,
});
this.createOpen = false;
ToastUtils.success(this.$t("sticker_packs.created"));
await this.loadPacks();
} catch (e) {
const err = e?.response?.data?.error || "create_failed";
ToastUtils.error(`${this.$t("sticker_packs.create_failed")}: ${err}`);
}
},
openEditor(packId = null) {
this.editorPackId = packId;
this.editorOpen = true;
},
onEditorClose() {
this.editorOpen = false;
this.editorPackId = null;
},
async onEditorSaved() {
await this.loadPacks();
},
async exportPack(pack) {
try {
const r = await window.api.get(`/api/v1/sticker-packs/${pack.id}/export`);
const blob = new Blob([JSON.stringify(r.data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const safe =
(pack.short_name || pack.title || "pack").toLowerCase().replace(/[^a-z0-9_-]+/g, "_") || "pack";
a.href = url;
a.download = `${safe}.meshchatxpack.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
ToastUtils.success(this.$t("sticker_packs.exported"));
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("sticker_packs.export_failed"));
}
},
async deletePack(pack) {
const confirmed = await DialogUtils.confirm(this.$t("sticker_packs.confirm_delete", { title: pack.title }));
if (!confirmed) return;
try {
await window.api.delete(`/api/v1/sticker-packs/${pack.id}?with_stickers=true`);
ToastUtils.success(this.$t("sticker_packs.deleted"));
await this.loadPacks();
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("sticker_packs.delete_failed"));
}
},
triggerInstallInput() {
this.$refs.installFileInput?.click();
},
async onInstallFile(event) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
const text = await file.text();
const doc = JSON.parse(text);
const r = await window.api.post("/api/v1/sticker-packs/install", { ...doc, replace_duplicates: false });
const data = r.data || {};
ToastUtils.success(
this.$t("sticker_packs.installed", {
imported: data.imported || 0,
})
);
await this.loadPacks();
} catch (e) {
const err = e?.response?.data?.error || "install_failed";
ToastUtils.error(`${this.$t("sticker_packs.install_failed")}: ${err}`);
}
},
},
};
</script>

View File

@@ -0,0 +1,212 @@
<template>
<div ref="stickerRoot" :class="['sticker-view', sizeClass]">
<video v-if="isVideo" ref="videoEl" :src="src" class="sticker-media" loop muted playsinline @error="onError" />
<div v-else-if="isAnimated" ref="lottieMount" class="sticker-media" />
<img
v-else
:src="src"
class="sticker-media"
decoding="async"
loading="lazy"
:alt="alt || ''"
@error="onError"
/>
</div>
</template>
<script>
import { attachInView } from "../../js/inViewObserver.js";
import { decodeTgsBuffer } from "../../js/tgsDecode.js";
export default {
name: "StickerView",
props: {
src: { type: String, required: true },
imageType: { type: String, default: "" },
alt: { type: String, default: "" },
size: { type: String, default: "auto" },
},
emits: ["error"],
data() {
return {
lottieAnim: null,
destroyed: false,
inView: false,
ioCleanup: null,
};
},
computed: {
isVideo() {
return (this.imageType || "").toLowerCase() === "webm";
},
isAnimated() {
return (this.imageType || "").toLowerCase() === "tgs";
},
sizeClass() {
return `sticker-view--${this.size}`;
},
},
watch: {
inView() {
this.onInViewChanged();
},
src() {
if (this.isAnimated) {
this.teardownLottie();
this.$nextTick(() => {
if (this.inView) {
this.mountLottie();
}
});
} else if (this.isVideo) {
this.$nextTick(() => this.syncVideoPlayback());
}
},
imageType() {
this.teardownLottie();
if (this.isAnimated) {
this.$nextTick(() => {
if (this.inView) {
this.mountLottie();
}
});
}
},
},
mounted() {
this.$nextTick(() => this.setupInView());
},
beforeUnmount() {
this.destroyed = true;
if (this.ioCleanup) {
this.ioCleanup();
this.ioCleanup = null;
}
this.teardownLottie();
},
methods: {
setupInView() {
const el = this.$refs.stickerRoot;
if (!el) {
return;
}
this.ioCleanup = attachInView(el, (entry) => {
this.inView = entry.isIntersecting;
});
},
onInViewChanged() {
if (this.isVideo) {
this.syncVideoPlayback();
}
if (this.isAnimated) {
if (this.inView && !this.lottieAnim && !this.destroyed) {
this.$nextTick(() => this.mountLottie());
} else {
this.syncLottiePlayback();
}
}
},
syncVideoPlayback() {
const v = this.$refs.videoEl;
if (!v || !this.isVideo) {
return;
}
if (this.inView) {
v.play?.().catch(() => {});
} else {
v.pause?.();
}
},
syncLottiePlayback() {
if (!this.lottieAnim || !this.isAnimated) {
return;
}
try {
if (this.inView) {
this.lottieAnim.play();
} else {
this.lottieAnim.pause();
}
} catch (e) {
console.warn(e);
}
},
async mountLottie() {
if (!this.$refs.lottieMount || !this.src || !this.inView) {
return;
}
this.teardownLottie();
try {
const lottie = await import("lottie-web/build/player/lottie_light.js");
const lib = lottie.default || lottie;
const response = await fetch(this.src);
const buf = await response.arrayBuffer();
const data = await decodeTgsBuffer(buf);
if (this.destroyed || !this.inView) {
return;
}
this.lottieAnim = lib.loadAnimation({
container: this.$refs.lottieMount,
renderer: "svg",
loop: true,
autoplay: false,
animationData: data,
});
this.syncLottiePlayback();
} catch (e) {
console.error("Failed to render TGS sticker", e);
this.$emit("error", e);
}
},
teardownLottie() {
if (this.lottieAnim) {
try {
this.lottieAnim.destroy();
} catch (e) {
console.warn(e);
}
this.lottieAnim = null;
}
},
onError(e) {
this.$emit("error", e);
},
},
};
</script>
<style scoped>
.sticker-view {
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
line-height: 0;
}
.sticker-view--auto {
width: 100%;
height: 100%;
}
.sticker-view--xs {
width: 32px;
height: 32px;
}
.sticker-view--sm {
width: 56px;
height: 56px;
}
.sticker-view--md {
width: 96px;
height: 96px;
}
.sticker-view--lg {
width: 192px;
height: 192px;
}
.sticker-media {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
</style>

View File

@@ -119,13 +119,13 @@
</div>
<div class="px-4 py-4 space-y-3 text-gray-900 dark:text-gray-100">
<p class="text-xs text-gray-600 dark:text-gray-400">
Paste an LXMF URI to decode and add it to your conversations.
Paste an LXMF, LXMA, or LXM URI to decode and ingest.
</p>
<div class="flex flex-col sm:flex-row gap-2">
<input
v-model="ingestUri"
type="text"
placeholder="lxmf://..."
placeholder="lxmf://... or lxma://..."
class="input-field flex-1 min-w-0 font-mono text-sm"
@keydown.enter="ingestPaperMessage"
/>
@@ -137,6 +137,15 @@
<MaterialDesignIcon icon-name="content-paste" class="size-5" />
<span class="sm:hidden text-sm font-medium">Paste</span>
</button>
<button
v-if="cameraSupported"
type="button"
class="inline-flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors shrink-0"
@click="openIngestScannerModal"
>
<MaterialDesignIcon icon-name="qrcode-scan" class="size-5" />
<span class="sm:hidden text-sm font-medium">{{ $t("messages.scan_qr") }}</span>
</button>
</div>
<button
type="button"
@@ -146,6 +155,9 @@
>
Read LXM
</button>
<p v-if="!cameraSupported" class="text-xs text-gray-500 dark:text-zinc-400">
{{ $t("messages.camera_not_supported") }}
</p>
</div>
</section>
</div>
@@ -243,6 +255,37 @@
</div>
</div>
</div>
<div
v-if="isIngestScannerModalOpen"
class="fixed inset-0 z-[210] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
@click.self="closeIngestScannerModal"
>
<div class="w-full max-w-xl rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl overflow-hidden">
<div class="px-5 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-zinc-100">{{ $t("messages.scan_qr") }}</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
@click="closeIngestScannerModal"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="p-5 space-y-3">
<video
ref="ingestScannerVideo"
class="w-full rounded-xl bg-black max-h-[60vh]"
autoplay
playsinline
muted
></video>
<div class="text-sm text-gray-500 dark:text-zinc-400">
{{ ingestScannerError || $t("messages.scanner_hint") }}
</div>
</div>
</div>
</div>
</div>
</template>
@@ -264,18 +307,30 @@ export default {
generatedUri: null,
ingestUri: "",
isSending: false,
isIngestScannerModalOpen: false,
ingestScannerError: null,
ingestScannerStream: null,
ingestScannerAnimationFrame: null,
};
},
computed: {
canGenerate() {
return this.destinationHash.length === 32 && this.content.length > 0;
},
cameraSupported() {
return (
typeof window !== "undefined" &&
typeof window.BarcodeDetector !== "undefined" &&
navigator?.mediaDevices?.getUserMedia
);
},
},
mounted() {
WebSocketConnection.on("message", this.onWebsocketMessage);
},
beforeUnmount() {
WebSocketConnection.off("message", this.onWebsocketMessage);
this.stopIngestScanner();
},
methods: {
async onWebsocketMessage(message) {
@@ -353,6 +408,89 @@ export default {
ToastUtils.error(this.$t("messages.failed_read_clipboard"));
}
},
async openIngestScannerModal() {
this.ingestScannerError = null;
this.isIngestScannerModalOpen = true;
await this.$nextTick();
await this.startIngestScanner();
},
closeIngestScannerModal() {
this.isIngestScannerModalOpen = false;
this.stopIngestScanner();
},
async startIngestScanner() {
if (!this.cameraSupported) {
this.ingestScannerError = this.$t("messages.camera_not_supported");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
audio: false,
});
this.ingestScannerStream = stream;
const video = this.$refs.ingestScannerVideo;
if (!video) {
this.ingestScannerError = this.$t("messages.camera_failed");
this.stopIngestScanner();
return;
}
video.srcObject = stream;
await video.play();
this.detectIngestQrLoop();
} catch (e) {
this.ingestScannerError = this.describeCameraError(e);
}
},
detectIngestQrLoop() {
if (!this.isIngestScannerModalOpen) return;
const video = this.$refs.ingestScannerVideo;
if (!video || video.readyState < 2) {
this.ingestScannerAnimationFrame = requestAnimationFrame(() => this.detectIngestQrLoop());
return;
}
const detector = new window.BarcodeDetector({ formats: ["qr_code"] });
detector
.detect(video)
.then((barcodes) => {
const qr = barcodes?.[0]?.rawValue?.trim();
if (!qr) {
this.ingestScannerAnimationFrame = requestAnimationFrame(() => this.detectIngestQrLoop());
return;
}
if (!/^lxm(a|f)?:\/\//i.test(qr)) {
ToastUtils.error(this.$t("messages.invalid_qr_uri"));
this.ingestScannerAnimationFrame = requestAnimationFrame(() => this.detectIngestQrLoop());
return;
}
this.ingestUri = qr;
this.closeIngestScannerModal();
this.ingestPaperMessage();
})
.catch(() => {
this.ingestScannerAnimationFrame = requestAnimationFrame(() => this.detectIngestQrLoop());
});
},
stopIngestScanner() {
if (this.ingestScannerAnimationFrame) {
cancelAnimationFrame(this.ingestScannerAnimationFrame);
this.ingestScannerAnimationFrame = null;
}
if (this.ingestScannerStream) {
this.ingestScannerStream.getTracks().forEach((track) => track.stop());
this.ingestScannerStream = null;
}
},
describeCameraError(error) {
const name = error?.name || "";
if (name === "NotAllowedError" || name === "SecurityError") {
return this.$t("messages.camera_permission_denied");
}
if (name === "NotFoundError" || name === "DevicesNotFoundError") {
return this.$t("messages.camera_not_found");
}
return this.$t("messages.camera_failed");
},
async copyUri() {
try {
await navigator.clipboard.writeText(this.generatedUri);

View File

@@ -0,0 +1,262 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<div
class="flex flex-wrap items-center justify-between gap-2 px-3 sm:px-4 py-2 border-b border-gray-200 dark:border-zinc-800 bg-slate-50/95 dark:bg-zinc-950/95 backdrop-blur-sm shrink-0 min-w-0"
>
<div class="flex items-center gap-2 sm:gap-3 min-w-0">
<div class="bg-blue-100 dark:bg-blue-900/30 p-1.5 rounded-xl shrink-0">
<MaterialDesignIcon icon-name="file-cog" class="size-5 text-blue-600 dark:text-blue-400" />
</div>
<div class="flex flex-col min-w-0">
<h1
class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider truncate leading-tight"
>
{{ $t("tools.reticulum_config_editor.title") }}
</h1>
<span
v-if="configPath"
class="text-[10px] text-gray-500 dark:text-gray-400 truncate leading-tight"
:title="configPath"
>
{{ configPath }}
</span>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
<button type="button" class="secondary-chip !py-1 !px-3" :disabled="loading" @click="loadConfig">
<MaterialDesignIcon icon-name="refresh" class="w-3.5 h-3.5" />
<span class="hidden sm:inline">{{ $t("tools.reticulum_config_editor.reload") }}</span>
</button>
<button
type="button"
class="secondary-chip !py-1 !px-3 !text-red-500 hover:!bg-red-50 dark:hover:!bg-red-900/20"
:disabled="loading || resetting"
@click="restoreDefaults"
>
<MaterialDesignIcon icon-name="restore" class="w-3.5 h-3.5" />
<span class="hidden sm:inline">{{ $t("tools.reticulum_config_editor.restore_defaults") }}</span>
</button>
<button
type="button"
class="secondary-chip !py-1 !px-3"
:disabled="!isDirty || saving"
@click="discardChanges"
>
<MaterialDesignIcon icon-name="undo" class="w-3.5 h-3.5" />
<span class="hidden sm:inline">{{ $t("tools.reticulum_config_editor.discard") }}</span>
</button>
<button
type="button"
class="primary-chip !py-1 !px-3"
:disabled="!isDirty || saving"
@click="saveConfig"
>
<MaterialDesignIcon icon-name="content-save" class="w-3.5 h-3.5" />
<span class="hidden sm:inline">{{
saving ? $t("tools.reticulum_config_editor.saving") : $t("tools.reticulum_config_editor.save")
}}</span>
</button>
</div>
</div>
<div
class="flex-1 overflow-y-auto overflow-x-hidden w-full px-3 sm:px-5 py-4 pb-[max(1rem,env(safe-area-inset-bottom))]"
>
<div class="space-y-4 w-full min-w-0 max-w-6xl mx-auto">
<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"
>
<div class="flex items-center gap-3">
<MaterialDesignIcon icon-name="alert" class="w-6 h-6" />
<div>
<div class="text-lg font-semibold">
{{ $t("tools.reticulum_config_editor.restart_required") }}
</div>
<div class="text-sm">
{{ $t("tools.reticulum_config_editor.restart_description") }}
</div>
</div>
</div>
<button
type="button"
class="ml-auto inline-flex items-center gap-2 rounded-full bg-white px-4 py-1.5 text-sm font-bold text-amber-600 hover:bg-white/90 transition shadow-sm disabled:opacity-50"
:disabled="reloadingRns"
:class="reloadingRns ? '' : 'animate-pulse motion-reduce:animate-none'"
@click="reloadRns"
>
<MaterialDesignIcon icon-name="restart" class="w-4 h-4" />
{{ reloadingRns ? $t("app.reloading_rns") : $t("tools.reticulum_config_editor.restart_now") }}
</button>
</div>
<div
class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 overflow-hidden"
>
<div
class="flex flex-wrap items-center justify-between gap-2 px-3 py-2 border-b border-gray-200 dark:border-zinc-800 bg-gray-50 dark:bg-zinc-900/60 text-xs text-gray-600 dark:text-gray-300"
>
<span class="flex items-center gap-1.5">
<MaterialDesignIcon icon-name="information-outline" class="w-3.5 h-3.5" />
{{ $t("tools.reticulum_config_editor.info") }}
</span>
<span v-if="isDirty" class="text-amber-600 dark:text-amber-400 font-semibold">
{{ $t("tools.reticulum_config_editor.unsaved") }}
</span>
</div>
<textarea
ref="editorRef"
v-model="content"
spellcheck="false"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
:placeholder="loading ? $t('tools.reticulum_config_editor.loading') : ''"
class="w-full bg-white dark:bg-zinc-900 text-gray-900 dark:text-white p-4 font-mono text-xs sm:text-sm resize-none focus:outline-none min-h-[420px] sm:min-h-[60vh]"
@keydown.tab.prevent="insertTab"
></textarea>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import DialogUtils from "../../js/DialogUtils";
import GlobalState from "../../js/GlobalState";
export default {
name: "ReticulumConfigEditorPage",
components: {
MaterialDesignIcon,
},
beforeRouteLeave(to, from, next) {
if (!this.isDirty) {
next();
return;
}
DialogUtils.confirm(this.$t("tools.reticulum_config_editor.confirm_leave")).then((ok) => {
next(!!ok);
});
},
data() {
return {
content: "",
originalContent: "",
configPath: "",
loading: false,
saving: false,
resetting: false,
reloadingRns: false,
hasSavedChanges: false,
};
},
computed: {
isDirty() {
return this.content !== this.originalContent;
},
showRestartReminder() {
return this.hasSavedChanges || GlobalState.hasPendingInterfaceChanges;
},
},
async mounted() {
await this.loadConfig();
},
methods: {
async loadConfig() {
if (this.loading) return;
try {
this.loading = true;
const response = await window.api.get("/api/v1/reticulum/config/raw");
this.content = response.data.content || "";
this.originalContent = this.content;
this.configPath = response.data.path || "";
} catch (e) {
ToastUtils.error(e.response?.data?.error || this.$t("tools.reticulum_config_editor.failed_load"));
} finally {
this.loading = false;
}
},
async saveConfig() {
if (this.saving || !this.isDirty) return;
try {
this.saving = true;
ToastUtils.loading(this.$t("tools.reticulum_config_editor.saving"), 0, "rns-config-save");
const response = await window.api.put("/api/v1/reticulum/config/raw", {
content: this.content,
});
this.originalContent = this.content;
this.configPath = response.data.path || this.configPath;
this.hasSavedChanges = true;
GlobalState.hasPendingInterfaceChanges = true;
ToastUtils.success(response.data.message || this.$t("tools.reticulum_config_editor.saved"));
} catch (e) {
ToastUtils.error(e.response?.data?.error || this.$t("tools.reticulum_config_editor.failed_save"));
} finally {
ToastUtils.dismiss("rns-config-save");
this.saving = false;
}
},
async restoreDefaults() {
if (this.resetting) return;
const confirmed = await DialogUtils.confirm(this.$t("tools.reticulum_config_editor.confirm_restore"));
if (!confirmed) return;
try {
this.resetting = true;
ToastUtils.loading(this.$t("tools.reticulum_config_editor.restoring"), 0, "rns-config-restore");
const response = await window.api.post("/api/v1/reticulum/config/reset");
this.content = response.data.content || "";
this.originalContent = this.content;
this.configPath = response.data.path || this.configPath;
this.hasSavedChanges = true;
GlobalState.hasPendingInterfaceChanges = true;
ToastUtils.success(response.data.message || this.$t("tools.reticulum_config_editor.restored"));
} catch (e) {
ToastUtils.error(e.response?.data?.error || this.$t("tools.reticulum_config_editor.failed_restore"));
} finally {
ToastUtils.dismiss("rns-config-restore");
this.resetting = false;
}
},
discardChanges() {
if (!this.isDirty) return;
this.content = this.originalContent;
},
async reloadRns() {
if (this.reloadingRns) return;
try {
this.reloadingRns = true;
ToastUtils.loading(this.$t("app.reloading_rns"), 0, "rns-config-reload");
const response = await window.api.post("/api/v1/reticulum/reload");
ToastUtils.success(response.data.message || this.$t("tools.reticulum_config_editor.restart_done"));
this.hasSavedChanges = false;
GlobalState.hasPendingInterfaceChanges = false;
if (GlobalState.modifiedInterfaceNames?.clear) {
GlobalState.modifiedInterfaceNames.clear();
}
await this.loadConfig();
} catch (e) {
ToastUtils.error(e.response?.data?.error || this.$t("tools.reticulum_config_editor.failed_restart"));
} finally {
ToastUtils.dismiss("rns-config-reload");
this.reloadingRns = false;
}
},
insertTab(event) {
const target = event.target;
const start = target.selectionStart;
const end = target.selectionEnd;
const before = this.content.substring(0, start);
const after = this.content.substring(end);
this.content = `${before} ${after}`;
this.$nextTick(() => {
target.selectionStart = target.selectionEnd = start + 2;
});
},
},
};
</script>

View File

@@ -6,9 +6,6 @@
<div class="border-b border-gray-200 dark:border-zinc-800 px-4 py-4 md:px-6 md:py-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between lg:gap-6">
<div class="space-y-2 min-w-0 flex-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
</div>
<div class="text-2xl md:text-3xl font-black text-gray-900 dark:text-white tracking-tight">
{{ $t("tools.power_tools") }}
</div>
@@ -225,6 +222,14 @@ export default {
titleKey: "tools.micron_editor.title",
descriptionKey: "tools.micron_editor.description",
},
{
name: "reticulum-config-editor",
route: { name: "reticulum-config-editor" },
icon: "file-cog",
iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
titleKey: "tools.reticulum_config_editor.title",
descriptionKey: "tools.reticulum_config_editor.description",
},
{
name: "paper-message",
route: { name: "paper-message" },

View File

@@ -0,0 +1,37 @@
export function isAnimatedRasterType(imageType) {
const s = String(imageType || "").toLowerCase();
return s === "gif" || s === "webp";
}
/**
* @param {Element} el
* @param {(entry: IntersectionObserverEntry) => void} callback
* @param {IntersectionObserverInit} [options]
* @returns {() => void} disconnect
*/
export function attachInView(el, callback, options = {}) {
if (!el) {
return () => {};
}
if (typeof IntersectionObserver === "undefined") {
callback({ isIntersecting: true, target: el });
return () => {};
}
const io = new IntersectionObserver(
(entries) => {
const e = entries[0];
if (e) {
callback(e);
}
},
{
threshold: options.threshold ?? 0.06,
rootMargin: options.rootMargin ?? "120px 0px",
...options,
}
);
io.observe(el);
return () => {
io.disconnect();
};
}

View File

@@ -0,0 +1,52 @@
/** Max incoming direct LXMF delivery size (matches server clamp). */
export const INCOMING_DELIVERY_MAX_BYTES = 1_000_000_000;
export const INCOMING_DELIVERY_PRESET_BYTES = Object.freeze({
"1mb": 1_000_000,
"10mb": 10_000_000,
"25mb": 25_000_000,
"50mb": 50_000_000,
"1gb": 1_000_000_000,
});
export function clampIncomingDeliveryBytes(bytes) {
const n = Number(bytes);
if (!Number.isFinite(n)) {
return 10_000_000;
}
return Math.min(INCOMING_DELIVERY_MAX_BYTES, Math.max(1000, Math.round(n)));
}
function incomingDeliveryCustomFieldsFromBytes(bytes) {
const b = clampIncomingDeliveryBytes(bytes);
if (b >= 1_000_000_000 && b % 1_000_000_000 === 0) {
return { amount: b / 1_000_000_000, unit: "gb" };
}
return { amount: Math.round((b / 1_000_000) * 1_000_000) / 1_000_000, unit: "mb" };
}
export function syncIncomingDeliveryFieldsFromBytes(bytes) {
const b = clampIncomingDeliveryBytes(bytes);
for (const [key, v] of Object.entries(INCOMING_DELIVERY_PRESET_BYTES)) {
if (v === b) {
const cf = incomingDeliveryCustomFieldsFromBytes(b);
return { preset: key, customAmount: cf.amount, customUnit: cf.unit };
}
}
const cf = incomingDeliveryCustomFieldsFromBytes(b);
return { preset: "custom", customAmount: cf.amount, customUnit: cf.unit };
}
export function incomingDeliveryBytesFromCustom(amount, unit) {
const a = Number(amount);
if (!Number.isFinite(a) || a <= 0) {
return 10_000_000;
}
const u = unit === "gb" ? "gb" : "mb";
const raw = u === "gb" ? a * 1_000_000_000 : a * 1_000_000;
return clampIncomingDeliveryBytes(raw);
}
export function incomingDeliveryBytesFromPresetKey(presetKey) {
return INCOMING_DELIVERY_PRESET_BYTES[presetKey] ?? null;
}

View File

@@ -39,6 +39,13 @@ export async function clearStickers(api) {
await api.delete("/api/v1/maintenance/stickers");
}
/**
* @param {{ delete: (path: string) => Promise<unknown> }} api
*/
export async function clearGifs(api) {
await api.delete("/api/v1/maintenance/gifs");
}
/**
* @param {{ delete: (path: string) => Promise<unknown> }} api
*/
@@ -73,3 +80,17 @@ export async function fetchStickerCount(api) {
return 0;
}
}
/**
* @param {{ get: (path: string) => Promise<{ data?: { gifs?: unknown[] } }> }} api
* @returns {Promise<number>}
*/
export async function fetchGifCount(api) {
try {
const response = await api.get("/api/v1/gifs");
const list = response.data?.gifs;
return Array.isArray(list) ? list.length : 0;
} catch {
return 0;
}
}

View File

@@ -0,0 +1,20 @@
/**
* Decompress a .tgs payload (gzip) or parse raw JSON. Used by sticker TGS playback.
*
* @param {ArrayBuffer} buf
* @returns {Promise<object>}
*/
export async function decodeTgsBuffer(buf) {
const view = new Uint8Array(buf);
if (view.length >= 2 && view[0] === 0x1f && view[1] === 0x8b) {
if (typeof DecompressionStream !== "undefined") {
const ds = new DecompressionStream("gzip");
const stream = new Blob([buf]).stream().pipeThrough(ds);
const out = await new Response(stream).text();
return JSON.parse(out);
}
throw new Error("DecompressionStream not available");
}
const text = new TextDecoder().decode(view);
return JSON.parse(text);
}

View File

@@ -184,6 +184,11 @@ const router = createRouter({
path: "/micron-editor",
component: defineAsyncComponent(() => import("./components/micron-editor/MicronEditorPage.vue")),
},
{
name: "reticulum-config-editor",
path: "/tools/reticulum-config-editor",
component: defineAsyncComponent(() => import("./components/tools/ReticulumConfigEditorPage.vue")),
},
{
name: "mesh-server",
path: "/mesh-server",