feat(conversation): add detailed outbound send status toggle and improve configuration handling

This commit is contained in:
Ivan
2026-04-12 17:02:59 -05:00
parent a888a17b7e
commit af73dcebbb
10 changed files with 1989 additions and 823 deletions

View File

@@ -562,7 +562,7 @@ import { useTheme } from "vuetify";
import SidebarLink from "./SidebarLink.vue";
import DialogUtils from "../js/DialogUtils";
import WebSocketConnection from "../js/WebSocketConnection";
import GlobalState from "../js/GlobalState";
import GlobalState, { mergeGlobalConfig } from "../js/GlobalState";
import Utils from "../js/Utils";
import GlobalEmitter from "../js/GlobalEmitter";
import NotificationUtils from "../js/NotificationUtils";
@@ -712,6 +712,14 @@ export default {
this.toneGenerator.stop();
},
mounted() {
try {
const v = localStorage.getItem("meshchatx_detailed_outbound_send_status");
if (v === "true" || v === "false") {
GlobalState.detailedOutboundSendStatus = v === "true";
}
} catch {
// ignore
}
this.startShellAuthWatch();
if (ElectronUtils.isElectron()) {
window.electron.onProtocolLink((url) => {
@@ -820,9 +828,11 @@ export default {
this.$refs.tutorialModal?.show();
},
onConfigUpdatedExternally(newConfig) {
if (!newConfig) return;
if (!newConfig || typeof newConfig !== "object") {
return;
}
mergeGlobalConfig(newConfig);
this.config = newConfig;
GlobalState.config = newConfig;
this.displayName = newConfig.display_name;
},
applyThemePreference(theme) {
@@ -830,8 +840,9 @@ export default {
if (typeof document !== "undefined") {
document.documentElement.classList.toggle("dark", mode === "dark");
}
if (this.vuetifyTheme?.global?.name) {
this.vuetifyTheme.global.name.value = mode;
const themeName = this.vuetifyTheme?.global?.name;
if (themeName && typeof themeName === "object" && "value" in themeName) {
themeName.value = mode;
}
},
getHashPopoutValue() {
@@ -843,9 +854,12 @@ export default {
const json = JSON.parse(message.data);
switch (json.type) {
case "config": {
this.config = json.config;
GlobalState.config = json.config;
this.displayName = json.config.display_name;
const next = json?.config;
if (next && typeof next === "object") {
mergeGlobalConfig(next);
this.config = next;
this.displayName = next.display_name;
}
break;
}
case "keyboard_shortcuts": {
@@ -1020,9 +1034,12 @@ export default {
async getConfig() {
try {
const response = await window.api.get(`/api/v1/config`);
this.config = response.data.config;
GlobalState.config = response.data.config;
this.displayName = response.data.config.display_name;
const next = response.data?.config;
if (next && typeof next === "object") {
mergeGlobalConfig(next);
this.config = next;
this.displayName = next.display_name;
}
} catch (e) {
// do nothing if failed to load config
console.log(e);

View File

@@ -4,7 +4,7 @@
class="cursor-default relative inline-block text-left"
>
<!-- menu button -->
<div ref="dropdown-button" @click.stop="toggleMenu">
<div ref="dropdown-button" class="touch-manipulation" @click.stop="toggleMenu">
<slot name="button" />
</div>
@@ -58,9 +58,8 @@ export default {
this.isShowingMenu = false;
this.dropdownPosition = null;
},
onClickOutsideMenu(event) {
onClickOutsideMenu() {
if (this.isShowingMenu) {
event.preventDefault();
this.hideMenu();
}
},

View File

@@ -6,10 +6,29 @@
</IconButton>
</template>
<template #items>
<DropDownMenuItem v-if="hasFailedMessages" @click="$emit('retry-failed')">
<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>
</DropDownMenuItem>
<DropDownMenuItem @click="$emit('share-contact')">
<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>Popout Chat</span>
<span>{{ $t("messages.pop_out_chat") }}</span>
</DropDownMenuItem>
<!-- ping button -->
@@ -45,7 +64,7 @@
</div>
<!-- telemetry trust toggle -->
<div v-if="GlobalState.config.telemetry_enabled" class="border-t">
<div v-if="GlobalState.config?.telemetry_enabled" class="border-t">
<DropDownMenuItem @click="onToggleTelemetryTrust">
<MaterialDesignIcon
:icon-name="contact?.is_telemetry_trusted ? 'shield-check' : 'shield-outline'"
@@ -85,6 +104,10 @@ export default {
type: Object,
required: true,
},
hasFailedMessages: {
type: Boolean,
default: false,
},
},
emits: [
"conversation-deleted",
@@ -92,6 +115,10 @@ export default {
"block-status-changed",
"popout",
"view-telemetry-history",
"retry-failed",
"open-telemetry-history",
"start-call",
"share-contact",
],
data() {
return {
@@ -208,16 +235,13 @@ export default {
return;
}
// delete all lxmf messages from "us to destination" and from "destination to us"
try {
await window.api.delete(`/api/v1/lxmf-messages/conversation/${this.peer.destination_hash}`);
this.$emit("conversation-deleted");
} catch (e) {
DialogUtils.alert(this.$t("messages.failed_delete_history"));
console.log(e);
}
// fire callback
this.$emit("conversation-deleted");
},
async onSetCustomDisplayName() {
this.$emit("set-custom-display-name");

View File

@@ -19,6 +19,7 @@
:has-more-announces="hasMoreAnnounces"
:peers-search-term="peersSearchTerm"
:total-peers-count="totalPeersCount"
:pinned-peer-hashes="pinnedPeerHashes"
@conversation-click="onConversationClick"
@peer-click="onPeerClick"
@conversation-search-changed="onConversationSearchChanged"
@@ -36,6 +37,7 @@
@bulk-delete="onBulkDelete"
@export-folders="onExportFolders"
@import-folders="onImportFolders"
@toggle-conversation-pin="onToggleConversationPin"
/>
<div
@@ -123,7 +125,7 @@
import WebSocketConnection from "../../js/WebSocketConnection";
import MessagesSidebar from "./MessagesSidebar.vue";
import ConversationViewer from "./ConversationViewer.vue";
import GlobalState from "../../js/GlobalState";
import GlobalState, { mergeGlobalConfig } from "../../js/GlobalState";
import DialogUtils from "../../js/DialogUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
@@ -172,6 +174,8 @@ export default {
filterHasAttachmentsOnly: false,
isLoadingConversations: false,
pinnedPeerHashes: [],
isIngestModalOpen: false,
ingestUri: "",
};
@@ -216,6 +220,7 @@ export default {
this.getConfig();
this.getConversations();
this.loadConversationPins();
this.getFolders();
this.getLxmfDeliveryAnnounces();
@@ -272,7 +277,11 @@ export default {
async getConfig() {
try {
const response = await window.api.get(`/api/v1/config`);
this.config = response.data.config;
const next = response.data?.config;
if (next && typeof next === "object") {
mergeGlobalConfig(next);
this.config = next;
}
} catch (e) {
// do nothing if failed to load config
console.log(e);
@@ -282,7 +291,11 @@ export default {
const json = JSON.parse(message.data);
switch (json.type) {
case "config": {
this.config = json.config;
const next = json?.config;
if (next && typeof next === "object") {
mergeGlobalConfig(next);
this.config = next;
}
break;
}
case "announce": {
@@ -431,6 +444,25 @@ export default {
this.isLoadingMore = false;
}
},
async loadConversationPins() {
try {
const response = await window.api.get("/api/v1/lxmf/conversation-pins");
this.pinnedPeerHashes = response.data.peer_hashes || [];
} catch (e) {
console.log(e);
}
},
async onToggleConversationPin(destinationHash) {
try {
const response = await window.api.post("/api/v1/lxmf/conversation-pins/toggle", {
destination_hash: destinationHash,
});
this.pinnedPeerHashes = response.data.peer_hashes || [];
} catch (e) {
ToastUtils.error(this.$t("messages.failed_toggle_pin"));
console.log(e);
}
},
peerHashFromMessage(msg) {
return msg.is_incoming ? msg.source_hash : msg.destination_hash;
},

View File

@@ -303,6 +303,7 @@
GlobalState.config.banished_effect_enabled && isBlocked(conversation.destination_hash),
selectionMode,
selectedHashes.has(conversation.destination_hash),
pinnedSet.has(conversation.destination_hash),
timeAgoTick,
]"
class="flex cursor-pointer p-2 border-l-2 relative group conversation-item"
@@ -391,6 +392,13 @@
</div>
<div class="flex flex-col items-center justify-between ml-1 py-1 shrink-0">
<div class="flex items-center space-x-1">
<div
v-if="pinnedSet.has(conversation.destination_hash)"
class="text-blue-500 dark:text-blue-400"
title="Pinned"
>
<MaterialDesignIcon icon-name="pin" class="w-4 h-4" />
</div>
<div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4" />
</div>
@@ -432,6 +440,22 @@
<MaterialDesignIcon icon-name="email-open-outline" class="size-4 text-gray-400" />
<span class="font-medium">Mark as Read</span>
</button>
<button
v-if="contextMenu.targetHash"
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="togglePinFromContextMenu"
>
<MaterialDesignIcon
:icon-name="isContextTargetPinned ? 'pin-off' : 'pin'"
class="size-4 text-gray-400"
/>
<span class="font-medium">{{
isContextTargetPinned
? $t("messages.unpin_conversation")
: $t("messages.pin_conversation")
}}</span>
</button>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@@ -751,6 +775,10 @@ export default {
type: Number,
default: 0,
},
pinnedPeerHashes: {
type: Array,
default: () => [],
},
},
emits: [
"conversation-click",
@@ -770,6 +798,7 @@ export default {
"bulk-delete",
"export-folders",
"import-folders",
"toggle-conversation-pin",
],
data() {
let foldersExpanded = true;
@@ -816,8 +845,25 @@ export default {
blockedDestinations() {
return GlobalState.blockedDestinations;
},
pinnedSet() {
return new Set(this.pinnedPeerHashes || []);
},
isContextTargetPinned() {
return Boolean(this.contextMenu.targetHash && this.pinnedSet.has(this.contextMenu.targetHash));
},
displayedConversations() {
return this.conversations;
const list = [...this.conversations];
const pinned = this.pinnedSet;
const idx = new Map(list.map((c, i) => [c.destination_hash, i]));
list.sort((a, b) => {
const ap = pinned.has(a.destination_hash);
const bp = pinned.has(b.destination_hash);
if (ap !== bp) {
return ap ? -1 : 1;
}
return idx.get(a.destination_hash) - idx.get(b.destination_hash);
});
return list;
},
peersCount() {
return Object.keys(this.peers).length;
@@ -845,7 +891,10 @@ export default {
return this.conversations.some((c) => c.is_unread);
},
allSelected() {
return this.conversations.length > 0 && this.selectedHashes.size === this.conversations.length;
return (
this.displayedConversations.length > 0 &&
this.selectedHashes.size === this.displayedConversations.length
);
},
messageIconStyle() {
const size = GlobalState.config?.message_icon_size || 28;
@@ -906,7 +955,7 @@ export default {
if (this.allSelected) {
this.selectedHashes.clear();
} else {
this.conversations.forEach((c) => this.selectedHashes.add(c.destination_hash));
this.displayedConversations.forEach((c) => this.selectedHashes.add(c.destination_hash));
}
},
toggleSelectConversation(hash) {
@@ -930,6 +979,14 @@ export default {
// fetch contact info for trust status
await this.fetchContactForContextMenu(hash);
},
togglePinFromContextMenu() {
const h = this.contextMenu.targetHash;
if (!h) {
return;
}
this.$emit("toggle-conversation-pin", h);
this.contextMenu.show = false;
},
onFolderContextMenu(event) {
event.preventDefault();
// Show folder management menu

View File

@@ -1,118 +1,142 @@
<template>
<div class="relative inline-flex items-stretch rounded-xl shadow-sm">
<!-- send button -->
<button
:disabled="!canSendMessage"
type="button"
class="inline-flex items-center gap-2 rounded-l-xl px-4 py-2.5 text-sm font-semibold text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
:class="[
canSendMessage
? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500'
: 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 cursor-not-allowed',
]"
@click="send"
>
<svg
v-if="!isSendingMessage"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
/>
</svg>
<span v-if="isSendingMessage" class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Sending...
</span>
<span v-else>
<span v-if="deliveryMethod === 'direct'">Send (Direct)</span>
<span v-else-if="deliveryMethod === 'opportunistic'">Send (Opportunistic)</span>
<span v-else-if="deliveryMethod === 'propagated'">Send (Propagated)</span>
<span v-else>Send</span>
</span>
</button>
<div class="relative self-stretch">
<!-- dropdown button -->
<template v-if="compact">
<button
:disabled="!canSendMessage"
type="button"
class="border-l relative inline-flex items-center justify-center rounded-r-xl px-2.5 h-full text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
class="inline-flex items-center justify-center rounded-xl p-2.5 min-h-[44px] min-w-[44px] text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 touch-manipulation select-none"
:class="[
canSendMessage
? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500 border-blue-700 dark:border-blue-800'
: 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 border-gray-500 dark:border-zinc-600 cursor-not-allowed',
? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500'
: 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 cursor-not-allowed',
]"
@click="showMenu"
:title="compactTitle"
@pointerdown="onCompactPointerDown"
@pointerup="onCompactPointerUp"
@pointercancel="onCompactPointerCancel"
@click="onCompactClick"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<svg
v-if="!isSendingMessage"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
/>
</svg>
<span v-else class="text-xs font-semibold opacity-90">...</span>
</button>
<!-- dropdown menu -->
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
</template>
<template v-else>
<button
:disabled="!canSendMessage"
type="button"
class="inline-flex items-center gap-2 rounded-l-xl px-4 py-2.5 text-sm font-semibold text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
:class="[
canSendMessage
? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500'
: 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 cursor-not-allowed',
]"
:title="isSendingMessage ? sendingTooltip : ''"
@click="send"
>
<div
v-if="isShowingMenu"
v-click-outside="hideMenu"
class="absolute bottom-full right-0 mb-1 z-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none overflow-hidden min-w-[200px]"
<svg
v-if="!isSendingMessage"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<div class="py-1">
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap border-b border-gray-100 dark:border-zinc-800"
@click="setDeliveryMethod(null)"
>
Send Automatically
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="setDeliveryMethod('direct')"
>
Send over Direct Link
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="setDeliveryMethod('opportunistic')"
>
Send Opportunistically
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="setDeliveryMethod('propagated')"
>
Send to Propagation Node
</button>
</div>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
/>
</svg>
<span :class="isSendingMessage ? 'opacity-60' : ''">
<span v-if="deliveryMethod === 'direct'">Send (Direct)</span>
<span v-else-if="deliveryMethod === 'opportunistic'">Send (Opportunistic)</span>
<span v-else-if="deliveryMethod === 'propagated'">Send (Propagated)</span>
<span v-else>Send</span>
</span>
</button>
<div class="relative self-stretch">
<button
:disabled="!canSendMessage"
type="button"
class="border-l relative inline-flex items-center justify-center rounded-r-xl px-2.5 h-full text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
:class="[
canSendMessage
? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500 border-blue-700 dark:border-blue-800'
: 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 border-gray-500 dark:border-zinc-600 cursor-not-allowed',
]"
@click="showMenu"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</template>
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="isShowingMenu"
v-click-outside="hideMenu"
class="absolute bottom-full right-0 mb-1 z-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none overflow-hidden min-w-[200px]"
>
<div class="py-1">
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap border-b border-gray-100 dark:border-zinc-800"
@click="setDeliveryMethod(null)"
>
Send Automatically
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="setDeliveryMethod('direct')"
>
Send over Direct Link
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="setDeliveryMethod('opportunistic')"
>
Send Opportunistically
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="setDeliveryMethod('propagated')"
>
Send to Propagation Node
</button>
</div>
</Transition>
</div>
</div>
</Transition>
</div>
</template>
@@ -126,14 +150,74 @@ export default {
},
canSendMessage: Boolean,
isSendingMessage: Boolean,
compact: Boolean,
sendingTooltip: {
type: String,
default:
"Resolving route to peer (finding path). This can take a while on first contact or after links change. Paths are remembered until they expire.",
},
},
emits: ["delivery-method-changed", "send"],
data() {
return {
isShowingMenu: false,
compactLongPressTimer: null,
compactTapArmed: false,
};
},
computed: {
compactTitle() {
if (this.isSendingMessage) {
return this.sendingTooltip;
}
return "Send (hold for delivery options)";
},
},
beforeUnmount() {
this.clearCompactLongPressTimer();
},
methods: {
clearCompactLongPressTimer() {
if (this.compactLongPressTimer != null) {
clearTimeout(this.compactLongPressTimer);
this.compactLongPressTimer = null;
}
},
onCompactPointerDown() {
if (!this.compact || !this.canSendMessage) {
return;
}
this.compactTapArmed = true;
this.clearCompactLongPressTimer();
this.compactLongPressTimer = window.setTimeout(() => {
this.compactLongPressTimer = null;
this.compactTapArmed = false;
this.showMenu();
}, 500);
},
onCompactPointerUp() {
if (!this.compact) {
return;
}
this.clearCompactLongPressTimer();
},
onCompactPointerCancel() {
if (!this.compact) {
return;
}
this.clearCompactLongPressTimer();
this.compactTapArmed = false;
},
onCompactClick() {
if (!this.compact) {
return;
}
if (!this.compactTapArmed) {
return;
}
this.compactTapArmed = false;
this.send();
},
showMenu() {
this.isShowingMenu = true;
},

View File

@@ -45,9 +45,9 @@
</div>
<!-- header -->
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
<div class="flex items-center gap-1 p-2 border-b border-gray-300 dark:border-zinc-800 min-w-0">
<!-- favourite button -->
<div class="my-auto mr-2">
<div class="my-auto shrink-0 mr-1">
<IconButton
v-if="isFavourite(selectedNode.destination_hash)"
class="text-yellow-500 dark:text-yellow-300"
@@ -67,15 +67,15 @@
</div>
<!-- node info -->
<div class="my-auto dark:text-gray-100 flex-1 min-w-0 flex items-baseline gap-1">
<div class="my-auto dark:text-gray-100 flex-1 min-w-0 flex items-baseline gap-1 overflow-hidden">
<span
class="font-semibold truncate inline-block max-w-xs sm:max-w-sm flex-shrink"
class="font-semibold truncate inline-block min-w-0 max-w-[min(100%,12rem)] sm:max-w-xs md:max-w-sm"
:title="selectedNode.display_name"
>{{ selectedNode.display_name }}</span
>
<span
v-if="selectedNodePath"
class="text-sm cursor-pointer whitespace-nowrap flex-shrink-0"
class="text-sm cursor-pointer whitespace-nowrap flex-shrink-0 hidden sm:inline"
@click="onDestinationPathClick(selectedNodePath)"
>
- {{ selectedNodePath.hops }}
@@ -89,19 +89,8 @@
</span>
</div>
<!-- identify button -->
<div class="my-auto ml-auto mr-2">
<IconButton
class="text-gray-700 dark:text-gray-300"
title="Identify"
@click="identify(selectedNode.destination_hash)"
>
<MaterialDesignIcon icon-name="fingerprint" class="size-5" />
</IconButton>
</div>
<!-- archive button -->
<div v-if="pageArchives.length > 0 || nodePageContent" class="my-auto mr-2 relative">
<div v-if="pageArchives.length > 0 || nodePageContent" class="my-auto shrink-0 relative">
<IconButton
class="text-gray-700 dark:text-gray-300"
:class="{ 'text-blue-500 dark:text-blue-400': pageArchives.length > 0 }"
@@ -153,19 +142,21 @@
</div>
</div>
<!-- popout button -->
<div class="my-auto mr-2">
<div class="hidden md:flex items-center gap-1 shrink-0">
<IconButton
class="text-gray-700 dark:text-gray-300"
:title="$t('messages.pop_out_chat')"
:title="$t('nomadnet.identify')"
@click="identify(selectedNode.destination_hash)"
>
<MaterialDesignIcon icon-name="fingerprint" class="size-5" />
</IconButton>
<IconButton
class="text-gray-700 dark:text-gray-300"
:title="$t('nomadnet.pop_out_browser')"
@click="openNomadnetPopout"
>
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
</IconButton>
</div>
<!-- close button -->
<div class="my-auto mr-2">
<IconButton
class="text-gray-700 dark:text-gray-300"
:title="$t('common.cancel')"
@@ -174,27 +165,61 @@
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
</IconButton>
</div>
<DropDownMenu class="md:hidden shrink-0">
<template #button>
<IconButton :title="$t('messages.more_actions')" class="text-gray-700 dark:text-gray-300">
<MaterialDesignIcon icon-name="dots-horizontal" class="size-5" />
</IconButton>
</template>
<template #items>
<DropDownMenuItem @click="identify(selectedNode.destination_hash)">
<MaterialDesignIcon icon-name="fingerprint" class="size-5" />
<span>{{ $t("nomadnet.identify") }}</span>
</DropDownMenuItem>
<DropDownMenuItem @click="openNomadnetPopout">
<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>
</div>
<!-- browser navigation -->
<div class="flex items-center w-full border-gray-300 dark:border-zinc-800 border-b p-2 gap-1">
<IconButton title="Home" @click="loadNodePage(selectedNode.destination_hash, defaultNodePagePath)">
<div
class="flex items-center w-full min-w-0 border-gray-300 dark:border-zinc-800 border-b p-2 gap-0.5 overflow-x-auto"
>
<IconButton
class="shrink-0"
title="Home"
@click="loadNodePage(selectedNode.destination_hash, defaultNodePagePath)"
>
<MaterialDesignIcon icon-name="home" class="w-5 h-5" />
</IconButton>
<IconButton :title="$t('common.refresh')" @click="reloadNodePage">
<IconButton class="shrink-0" :title="$t('common.refresh')" @click="reloadNodePage">
<MaterialDesignIcon icon-name="refresh" class="w-5 h-5" />
</IconButton>
<IconButton
class="shrink-0"
:title="$t('app.toggle_source')"
:class="{ 'bg-green-500/10 text-green-600 dark:text-green-400': isShowingNodePageSource }"
@click="toggleNodePageSource"
>
<MaterialDesignIcon icon-name="code-tags" class="size-5" />
</IconButton>
<IconButton title="Back" :disabled="nodePagePathHistory.length === 0" @click="loadPreviousNodePage">
<IconButton
class="shrink-0"
title="Back"
:disabled="nodePagePathHistory.length === 0"
@click="loadPreviousNodePage"
>
<MaterialDesignIcon icon-name="arrow-left" class="w-5 h-5" />
</IconButton>
<div class="my-auto mx-1 w-full">
<div class="my-auto mx-1 min-w-0 flex-1">
<input
v-model="nodePagePathUrlInput"
type="text"
@@ -203,7 +228,7 @@
@keyup.enter="onNodePageUrlClick(nodePagePathUrlInput)"
/>
</div>
<IconButton title="Go" @click="onNodePageUrlClick(nodePagePathUrlInput)">
<IconButton class="shrink-0" title="Go" @click="onNodePageUrlClick(nodePagePathUrlInput)">
<MaterialDesignIcon icon-name="arrow-right" class="w-5 h-5" />
</IconButton>
</div>
@@ -362,6 +387,8 @@ import Utils from "../../js/Utils";
import ToastUtils from "../../js/ToastUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import IconButton from "../IconButton.vue";
import DropDownMenu from "../DropDownMenu.vue";
import DropDownMenuItem from "../DropDownMenuItem.vue";
import GlobalState from "../../js/GlobalState";
export default {
@@ -370,6 +397,8 @@ export default {
NomadNetworkSidebar,
MaterialDesignIcon,
IconButton,
DropDownMenu,
DropDownMenuItem,
},
props: {
destinationHash: {

View File

@@ -212,8 +212,8 @@ import LxmfUserIcon from "../LxmfUserIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import ColourPickerDropdown from "../ColourPickerDropdown.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import GlobalState from "../../js/GlobalState";
import GlobalEmitter from "../../js/GlobalEmitter";
import { mergeGlobalConfig } from "../../js/GlobalState";
export default {
name: "ProfileIconPage",
@@ -316,7 +316,11 @@ export default {
async getConfig() {
try {
const response = await window.api.get("/api/v1/config");
this.config = response.data.config;
const next = response.data?.config;
if (next && typeof next === "object") {
this.config = next;
mergeGlobalConfig(next);
}
} catch (e) {
ToastUtils.error(this.$t("messages.failed_load_config"));
console.error(e);
@@ -325,9 +329,13 @@ export default {
async updateConfig(config, silent = false) {
try {
const response = await window.api.patch("/api/v1/config", config);
this.config = response.data.config;
GlobalState.config = response.data.config;
GlobalEmitter.emit("config-updated", response.data.config);
const next = response.data?.config;
if (!next || typeof next !== "object") {
return false;
}
mergeGlobalConfig(next);
this.config = next;
GlobalEmitter.emit("config-updated", next);
this.saveOriginalValues();
if (!silent) {

View File

@@ -768,6 +768,26 @@
Message Bubbles
</div>
<div
class="flex items-start gap-3 rounded-xl border border-gray-200 dark:border-zinc-700 px-3 py-2.5"
>
<input
id="detailed-outbound-send-status"
type="checkbox"
class="mt-1 rounded border-gray-300 dark:border-zinc-600"
:checked="GlobalState.detailedOutboundSendStatus"
@change="onDetailedOutboundSendStatusChange"
/>
<label for="detailed-outbound-send-status" class="min-w-0 cursor-pointer">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.detailed_outbound_send_status") }}
</div>
<div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
{{ $t("app.detailed_outbound_send_status_description") }}
</div>
</label>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
@@ -1796,6 +1816,7 @@ import ShortcutRecorder from "./ShortcutRecorder.vue";
import KeyboardShortcuts from "../../js/KeyboardShortcuts";
import ElectronUtils from "../../js/ElectronUtils";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import GlobalState from "../../js/GlobalState";
export default {
name: "SettingsPage",
@@ -1807,6 +1828,7 @@ export default {
},
data() {
return {
GlobalState,
ElectronUtils,
KeyboardShortcuts,
config: {
@@ -2250,6 +2272,15 @@ export default {
);
}, 1000);
},
onDetailedOutboundSendStatusChange(event) {
const checked = event.target.checked;
GlobalState.detailedOutboundSendStatus = checked;
try {
localStorage.setItem("meshchatx_detailed_outbound_send_status", checked ? "true" : "false");
} catch {
// ignore
}
},
async onLanguageChange() {
await this.updateConfig(
{