mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-25 13:12:10 +00:00
feat(router): add new route for reticulum configuration editor and update command palette navigation
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
625
meshchatx/src/frontend/components/stickers/StickerEditor.vue
Normal file
625
meshchatx/src/frontend/components/stickers/StickerEditor.vue
Normal 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 }}°</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>
|
||||
@@ -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,
|
||||
})
|
||||
}}
|
||||
·
|
||||
{{ $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>
|
||||
212
meshchatx/src/frontend/components/stickers/StickerView.vue
Normal file
212
meshchatx/src/frontend/components/stickers/StickerView.vue
Normal 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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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" },
|
||||
|
||||
37
meshchatx/src/frontend/js/inViewObserver.js
Normal file
37
meshchatx/src/frontend/js/inViewObserver.js
Normal 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();
|
||||
};
|
||||
}
|
||||
52
meshchatx/src/frontend/js/settings/incomingDeliveryLimit.js
Normal file
52
meshchatx/src/frontend/js/settings/incomingDeliveryLimit.js
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
20
meshchatx/src/frontend/js/tgsDecode.js
Normal file
20
meshchatx/src/frontend/js/tgsDecode.js
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user