diff --git a/meshchatx/src/frontend/components/DropDownMenu.vue b/meshchatx/src/frontend/components/DropDownMenu.vue index 9228b81..c7bbde9 100644 --- a/meshchatx/src/frontend/components/DropDownMenu.vue +++ b/meshchatx/src/frontend/components/DropDownMenu.vue @@ -8,24 +8,28 @@ - - -
+ - -
-
+
+ +
+ + @@ -35,7 +39,7 @@ export default { data() { return { isShowingMenu: false, - dropdownClass: null, + dropdownPosition: null, }; }, methods: { @@ -52,6 +56,7 @@ export default { }, hideMenu() { this.isShowingMenu = false; + this.dropdownPosition = null; }, onClickOutsideMenu(event) { if (this.isShowingMenu) { @@ -61,39 +66,28 @@ export default { }, adjustDropdownPosition() { this.$nextTick(() => { - // find button and dropdown const button = this.$refs["dropdown-button"]; - if (!button) { - return; - } + if (!button) return; - const dropdown = button.parentElement?.querySelector(".absolute"); - if (!dropdown) { - return; - } - - // get bounding box of button const buttonRect = button.getBoundingClientRect(); + const estimatedHeight = 200; + const spaceBelow = window.innerHeight - buttonRect.bottom; + const spaceAbove = buttonRect.top; - // calculate how much space is under and above the button - const spaceBelowButton = window.innerHeight - buttonRect.bottom; - const spaceAboveButton = buttonRect.top; + let x = buttonRect.right - 224; + if (x < 8) x = 8; + if (x + 224 > window.innerWidth) x = window.innerWidth - 224 - 8; - // estimate dropdown height (will be measured after render) - const estimatedDropdownHeight = 150; - - // calculate if there is enough space available to show dropdown - const hasEnoughSpaceAboveButton = spaceAboveButton > estimatedDropdownHeight; - const hasEnoughSpaceBelowButton = spaceBelowButton > estimatedDropdownHeight; - - // show dropdown above button - if (hasEnoughSpaceAboveButton && !hasEnoughSpaceBelowButton) { - this.dropdownClass = "bottom-0 mb-12"; - return; + let y; + if (spaceBelow >= estimatedHeight || spaceBelow >= spaceAbove) { + y = buttonRect.bottom + 4; + } else { + y = buttonRect.top - estimatedHeight - 4; } + if (y < 8) y = 8; + if (y + estimatedHeight > window.innerHeight - 8) y = window.innerHeight - estimatedHeight - 8; - // otherwise fallback to showing dropdown below button - this.dropdownClass = "top-0 mt-12"; + this.dropdownPosition = { x, y }; }); }, }, diff --git a/meshchatx/src/frontend/components/blocked/BlockedPage.vue b/meshchatx/src/frontend/components/blocked/BlockedPage.vue index 76f281b..6e4834b 100644 --- a/meshchatx/src/frontend/components/blocked/BlockedPage.vue +++ b/meshchatx/src/frontend/components/blocked/BlockedPage.vue @@ -5,7 +5,7 @@ >
- +

{{ $t("banishment.title") }}

diff --git a/meshchatx/src/frontend/components/contacts/ContactsPage.vue b/meshchatx/src/frontend/components/contacts/ContactsPage.vue index 610b665..abde623 100644 --- a/meshchatx/src/frontend/components/contacts/ContactsPage.vue +++ b/meshchatx/src/frontend/components/contacts/ContactsPage.vue @@ -14,6 +14,19 @@ {{ $t("contacts.share_my_identity") }} + +
+ +
+
+
+

+ {{ $t("contacts.import_modal_title") }} +

+ +
+
+

+ {{ $t("contacts.import_file_hint") }} +

+ + +

{{ importError }}

+
+
+
+
{ + try { + const json = JSON.parse(e.target?.result || "{}"); + const contacts = json.contacts ?? (Array.isArray(json) ? json : []); + if (!Array.isArray(contacts) || contacts.length === 0) { + this.importError = this.$t("contacts.import_failed"); + return; + } + this.importContacts(contacts); + } catch { + this.importError = this.$t("contacts.import_failed"); + } + }; + reader.readAsText(file); + event.target.value = ""; + }, + async importContacts(contacts) { + try { + const response = await window.axios.post("/api/v1/telephone/contacts/import", { + contacts, + }); + const added = response.data?.added ?? 0; + ToastUtils.success(this.$t("contacts.import_success", { added })); + this.closeImportDialog(); + await this.getContacts(); + } catch (e) { + this.importError = e?.response?.data?.message || this.$t("contacts.import_failed"); + } + }, + async exportContacts() { + try { + const response = await window.axios.get("/api/v1/telephone/contacts/export"); + const contacts = response.data?.contacts ?? []; + const blob = new Blob([JSON.stringify({ contacts }, null, 2)], { + type: "application/json", + }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", "contacts_export.json"); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + ToastUtils.success(this.$t("contacts.export_success")); + } catch (e) { + ToastUtils.error(e?.response?.data?.message || this.$t("contacts.export_failed")); + } + }, closeAddDialog() { this.isAddDialogOpen = false; this.pendingLxmaImport = false; diff --git a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue index 6a8418e..eac0213 100644 --- a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue +++ b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue @@ -27,7 +27,7 @@
- + Banish User diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index 8f2d527..7d3838a 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -149,6 +149,16 @@ + + + + + +
@@ -1833,6 +1859,7 @@ export default { x: 0, y: 0, chatItem: null, + justOpened: false, }, now: Date.now(), updateTimer: null, @@ -2872,12 +2899,30 @@ export default { } }, async onConversationDeleted() { - // reload conversation await this.initialLoad(); - - // reload conversations this.$emit("reload-conversations"); }, + async onBanishHeaderClick() { + if (!this.selectedPeer) return; + if ( + !(await DialogUtils.confirm( + this.$t("messages.banish_confirm") || + "Are you sure you want to banish this user? They will not be able to send you messages or establish links." + )) + ) { + return; + } + try { + await window.axios.post("/api/v1/blocked-destinations", { + destination_hash: this.selectedPeer.destination_hash, + }); + GlobalEmitter.emit("block-status-changed"); + DialogUtils.alert(this.$t("messages.user_banished")); + } catch (e) { + DialogUtils.alert(this.$t("messages.failed_banish_user")); + console.error(e); + } + }, onChatItemClick: function (chatItem) { if (!chatItem.is_actions_expanded) { chatItem.is_actions_expanded = true; @@ -2922,28 +2967,28 @@ export default { }, onMessageContextMenu(event, chatItem) { this.messageContextMenu.chatItem = chatItem; + this.messageContextMenu.justOpened = true; this.messageContextMenu.show = true; - // wait for context menu to be rendered to calculate its width/height this.$nextTick(() => { - const menuWidth = 180; // approximate minimum width - const menuHeight = 150; // approximate height + const menuWidth = 180; + const menuHeight = 150; let x = event.clientX; let y = event.clientY; - // Adjust X if it would go off-screen on the right if (x + menuWidth > window.innerWidth) { x = window.innerWidth - menuWidth - 10; } - - // Adjust Y if it would go off-screen at the bottom if (y + menuHeight > window.innerHeight) { y = window.innerHeight - menuHeight - 10; } this.messageContextMenu.x = x; this.messageContextMenu.y = y; + setTimeout(() => { + this.messageContextMenu.justOpened = false; + }, 50); }); }, async showRawMessage(chatItem) { diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue index 8518c16..b978831 100644 --- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue +++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue @@ -425,39 +425,42 @@ export default { if (window.axios.isCancel(e)) return; } }, - async getPathTableBatch() { + async getPathTableBatch(destinationHashes = null) { this.pathTable = []; try { - this.loadingStatus = "Loading Paths..."; - const firstResp = await window.axios.get(`/api/v1/path-table`, { - params: { limit: this.pageSize, offset: 0 }, - signal: this.abortController.signal, - }); - this.pathTable.push(...firstResp.data.path_table); - const totalCount = firstResp.data.total_count; - - if (totalCount > this.pageSize) { - const remainingOffsets = []; - for (let offset = this.pageSize; offset < totalCount; offset += this.pageSize) { - remainingOffsets.push(offset); - } - - // Fetch remaining batches in parallel with limited concurrency to not overwhelm backend - const concurrency = 3; - for (let i = 0; i < remainingOffsets.length; i += concurrency) { - if (this.abortController.signal.aborted) return; - const chunk = remainingOffsets.slice(i, i + concurrency); - const promises = chunk.map((offset) => - window.axios.get(`/api/v1/path-table`, { - params: { limit: this.pageSize, offset: offset }, - signal: this.abortController.signal, - }) - ); - const responses = await Promise.all(promises); - for (const r of responses) { - this.pathTable.push(...r.data.path_table); + this.loadingStatus = "Loading paths..."; + if (destinationHashes && destinationHashes.length > 0) { + const resp = await window.axios.post(`/api/v1/path-table`, { destination_hashes: destinationHashes }, { + signal: this.abortController.signal, + }); + this.pathTable.push(...resp.data.path_table); + } else { + const firstResp = await window.axios.get(`/api/v1/path-table`, { + params: { limit: this.pageSize, offset: 0 }, + signal: this.abortController.signal, + }); + this.pathTable.push(...firstResp.data.path_table); + const totalCount = firstResp.data.total_count; + if (totalCount > this.pageSize) { + const concurrency = 3; + for (let offset = this.pageSize; offset < totalCount; offset += this.pageSize * concurrency) { + if (this.abortController.signal.aborted) return; + const chunk = []; + for (let i = 0; i < concurrency && offset + i * this.pageSize < totalCount; i++) { + chunk.push(offset + i * this.pageSize); + } + const promises = chunk.map((o) => + window.axios.get(`/api/v1/path-table`, { + params: { limit: this.pageSize, offset: o }, + signal: this.abortController.signal, + }) + ); + const responses = await Promise.all(promises); + for (const r of responses) { + this.pathTable.push(...r.data.path_table); + } + this.loadingStatus = `Loading paths (${this.pathTable.length} / ${totalCount})`; } - this.loadingStatus = `Loading Paths (${this.pathTable.length} / ${totalCount})`; } } } catch (e) { @@ -467,41 +470,26 @@ export default { }, async getAnnouncesBatch() { this.announces = {}; + const aspectsToFetch = ["lxmf.delivery", "nomadnetwork.node"]; try { - this.loadingStatus = "Loading Announces..."; - const firstResp = await window.axios.get(`/api/v1/announces`, { - params: { limit: this.pageSize, offset: 0 }, - signal: this.abortController.signal, - }); - - for (const announce of firstResp.data.announces) { - this.announces[announce.destination_hash] = announce; - } - const totalCount = firstResp.data.total_count; - - if (totalCount > this.pageSize) { - const remainingOffsets = []; - for (let offset = this.pageSize; offset < totalCount; offset += this.pageSize) { - remainingOffsets.push(offset); - } - - const concurrency = 3; - for (let i = 0; i < remainingOffsets.length; i += concurrency) { - if (this.abortController.signal.aborted) return; - const chunk = remainingOffsets.slice(i, i + concurrency); - const promises = chunk.map((offset) => - window.axios.get(`/api/v1/announces`, { - params: { limit: this.pageSize, offset: offset }, - signal: this.abortController.signal, - }) - ); - const responses = await Promise.all(promises); - for (const r of responses) { - for (const announce of r.data.announces) { - this.announces[announce.destination_hash] = announce; - } + for (const aspect of aspectsToFetch) { + if (this.abortController.signal.aborted) return; + this.loadingStatus = `Loading ${aspect}...`; + let offset = 0; + let hasMore = true; + while (hasMore) { + const resp = await window.axios.get(`/api/v1/announces`, { + params: { aspect, limit: this.pageSize, offset }, + signal: this.abortController.signal, + }); + for (const announce of resp.data.announces) { + this.announces[announce.destination_hash] = announce; } - this.loadingStatus = `Loading Announces (${Object.keys(this.announces).length} / ${totalCount})`; + const loaded = Object.keys(this.announces).length; + const total = resp.data.total_count; + this.loadingStatus = `Loading announces (${loaded})`; + offset += resp.data.announces.length; + hasMore = resp.data.announces.length === this.pageSize && offset < total; } } } catch (e) { @@ -1100,7 +1088,9 @@ export default { if (this.abortController.signal.aborted) return; this.loadingStatus = "Fetching network data..."; - await Promise.all([this.getPathTableBatch(), this.getAnnouncesBatch()]); + await this.getAnnouncesBatch(); + if (this.abortController.signal.aborted) return; + await this.getPathTableBatch(Object.keys(this.announces)); if (this.abortController.signal.aborted) return; await this.processVisualization(); diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue index 8c754c7..e3f3c59 100644 --- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue +++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue @@ -14,6 +14,7 @@ @node-click="onNodeClick" @rename-favourite="onRenameFavourite" @remove-favourite="onRemoveFavourite" + @add-favourite="addFavourite" @nodes-search-changed="onNodesSearchChanged" @load-more-nodes="loadMoreNodes" /> diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue index ebe20e6..b1d7970 100644 --- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue +++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue @@ -161,7 +161,7 @@
@@ -183,29 +183,53 @@
- -
- - + + + +
{{ section.name }}
-
+ + - -
+ + +
-
+
+
@@ -271,6 +298,7 @@ :key="node.destination_hash" class="announce-card relative" :class="{ 'announce-card--active': node.destination_hash === selectedDestinationHash }" + @contextmenu.prevent="openAnnounceContextMenu($event, node)" >
- +
+ +
@@ -340,6 +370,49 @@
{{ $t("nomadnet.listening_for_peers") }}
+ + + +
+ + + +
+
@@ -388,7 +461,14 @@ export default { default: false, }, }, - emits: ["node-click", "rename-favourite", "remove-favourite", "nodes-search-changed", "load-more-nodes"], + emits: [ + "node-click", + "rename-favourite", + "remove-favourite", + "add-favourite", + "nodes-search-changed", + "load-more-nodes", + ], data() { return { GlobalState, @@ -409,6 +489,7 @@ export default { y: 0, targetHash: null, targetSectionId: null, + justOpened: false, }, sectionContextMenu: { show: false, @@ -416,6 +497,13 @@ export default { y: 0, sectionId: null, }, + announceContextMenu: { + show: false, + x: 0, + y: 0, + node: null, + justOpened: false, + }, }; }, computed: { @@ -594,6 +682,36 @@ export default { isBlocked(identityHash) { return this.blockedDestinations.some((b) => b.destination_hash === identityHash); }, + isFavourite(destinationHash) { + return this.favourites.some((f) => f.destination_hash === destinationHash); + }, + addFavouriteFromContext() { + const node = this.announceContextMenu.node; + if (!node) { + this.closeContextMenus(); + return; + } + this.closeContextMenus(); + this.$emit("add-favourite", node); + }, + async blockAnnounceFromContext() { + const node = this.announceContextMenu.node; + if (!node) { + this.closeContextMenus(); + return; + } + this.closeContextMenus(); + await this.onBlockNode(node); + }, + async unblockAnnounceFromContext() { + const node = this.announceContextMenu.node; + if (!node) { + this.closeContextMenus(); + return; + } + this.closeContextMenus(); + await this.onUnblockNode(node.identity_hash); + }, async onBlockNode(node) { if (!(await DialogUtils.confirm(this.$t("nomadnet.block_node_confirm", { name: node.display_name })))) { return; @@ -742,12 +860,16 @@ export default { openFavouriteContextMenu(event, favourite, sectionId) { this.favouriteContextMenu = { show: true, - x: event.pageX || event.clientX, - y: event.pageY || event.clientY, + justOpened: true, + x: event.clientX, + y: event.clientY, targetHash: favourite.destination_hash, targetSectionId: sectionId, }; this.sectionContextMenu.show = false; + setTimeout(() => { + this.favouriteContextMenu.justOpened = false; + }, 50); }, openSectionContextMenu(event, section) { this.sectionContextMenu = { @@ -761,6 +883,21 @@ export default { closeContextMenus() { this.favouriteContextMenu.show = false; this.sectionContextMenu.show = false; + this.announceContextMenu.show = false; + }, + openAnnounceContextMenu(event, node) { + this.announceContextMenu = { + show: true, + justOpened: true, + x: event.clientX, + y: event.clientY, + node, + }; + this.favouriteContextMenu.show = false; + this.sectionContextMenu.show = false; + setTimeout(() => { + this.announceContextMenu.justOpened = false; + }, 50); }, getFavouriteByHash(hash) { return this.favourites.find((fav) => fav.destination_hash === hash); @@ -783,6 +920,47 @@ export default { this.closeContextMenus(); this.onRemoveFavourite(favourite); }, + async banishFavouriteFromContext() { + const favourite = this.getFavouriteByHash(this.favouriteContextMenu.targetHash); + if (!favourite) { + this.closeContextMenus(); + return; + } + this.closeContextMenus(); + if ( + !(await DialogUtils.confirm( + this.$t("nomadnet.block_node_confirm", { name: favourite.display_name || favourite.custom_display_name }) + )) + ) { + return; + } + try { + await window.axios.post("/api/v1/blocked-destinations", { + destination_hash: favourite.destination_hash, + }); + GlobalEmitter.emit("block-status-changed"); + DialogUtils.alert(this.$t("nomadnet.node_blocked_successfully")); + } catch (e) { + DialogUtils.alert(this.$t("nomadnet.failed_to_block_node")); + console.error(e); + } + }, + async unblockFavouriteFromContext() { + const hash = this.favouriteContextMenu.targetHash; + if (!hash) { + this.closeContextMenus(); + return; + } + this.closeContextMenus(); + try { + await window.axios.delete(`/api/v1/blocked-destinations/${hash}`); + GlobalEmitter.emit("block-status-changed"); + DialogUtils.alert(this.$t("nomadnet.banishment_lifted")); + } catch (e) { + DialogUtils.alert(this.$t("nomadnet.failed_lift_banishment")); + console.error(e); + } + }, moveContextFavouriteToSection(sectionId) { if (!this.favouriteContextMenu.targetHash) { return; diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue index 63981c6..4e73a9e 100644 --- a/meshchatx/src/frontend/components/settings/SettingsPage.vue +++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue @@ -1039,11 +1039,12 @@
+
+
+ {{ $t("app.announce_limits") }} +
+
+ {{ $t("app.announce_limits_description") }} +
+
+
+ + +
+
+ + +
+
+ + +
+
+
@@ -1679,6 +1738,9 @@ export default { banished_text: "BANISHED", banished_color: "#dc2626", blackhole_integration_enabled: true, + announce_limit_lxmf_delivery: null, + announce_limit_nomadnetwork_node: null, + announce_limit_lxmf_propagation: null, message_font_size: 14, message_icon_size: 28, message_outbound_bubble_color: "#4f46e5", @@ -1776,6 +1838,10 @@ export default { "Network Security", "app.blackhole_integration_enabled", "app.blackhole_integration_description", + "app.announce_limits", + "app.announce_limit_lxmf", + "app.announce_limit_nomadnet", + "app.announce_limit_prop", ], transport: [ "Reticulum",