From 37cc6aa158b7e5511b28474ec05ad9e42e4cb030 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Fri, 25 Jul 2025 21:56:02 +1200 Subject: [PATCH 01/31] add button to identify self to nomad network node --- meshchat.py | 24 ++++++++++++++ .../nomadnetwork/NomadNetworkPage.vue | 33 ++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/meshchat.py b/meshchat.py index 871df15..7026906 100644 --- a/meshchat.py +++ b/meshchat.py @@ -1758,6 +1758,30 @@ class ReticulumMeshChat: "lxmf_message": lxmf_message, }) + # identify self on existing nomadnetwork link + @routes.post("/api/v1/nomadnetwork/{destination_hash}/identify") + async def index(request): + + # get path params + destination_hash = request.match_info.get("destination_hash", "") + + # convert destination hash to bytes + destination_hash = bytes.fromhex(destination_hash) + + # identify to existing active link + if destination_hash in nomadnet_cached_links: + link = nomadnet_cached_links[destination_hash] + if link.status is RNS.Link.ACTIVE: + link.identify(self.identity) + return web.json_response({ + "message": "Identity has been sent!", + }) + + # failed to identify + return web.json_response({ + "message": "Failed to identify. No active link to destination.", + }, status=500) + # delete lxmf message @routes.delete("/api/v1/lxmf-messages/{hash}") async def index(request): diff --git a/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/src/frontend/components/nomadnetwork/NomadNetworkPage.vue index efca11e..ff06cf3 100644 --- a/src/frontend/components/nomadnetwork/NomadNetworkPage.vue +++ b/src/frontend/components/nomadnetwork/NomadNetworkPage.vue @@ -17,8 +17,21 @@ - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away - +
+
+
+
+ + + +
+
+
+
+ + +
@@ -773,6 +786,24 @@ export default { } }, + async identify(destinationHash) { + try { + + // ask user to confirm + if(!confirm("Are you sure you want to identify yourself to this NomadNetwork Node? The page will reload after your identity has been sent.")){ + return; + } + + // identify self to nomadnetwork node + await window.axios.post(`/api/v1/nomadnetwork/${destinationHash}/identify`); + + // reload page + this.reloadNodePage(); + + } catch(e) { + DialogUtils.alert(e.response?.data?.message ?? "Failed to identify!"); + } + }, downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) { try { From 4734e6246833adf785aab83485136e977a1d8024 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Fri, 25 Jul 2025 23:02:05 +1200 Subject: [PATCH 02/31] implement favourites system for nomadnetwork nodes --- database.py | 15 +++ meshchat.py | 101 ++++++++++++++++++ .../nomadnetwork/NomadNetworkPage.vue | 84 +++++++++++++++ .../nomadnetwork/NomadNetworkSidebar.vue | 82 +++++++++++++- src/frontend/js/Utils.js | 7 ++ 5 files changed, 288 insertions(+), 1 deletion(-) diff --git a/database.py b/database.py index 11a7427..c9ad27c 100644 --- a/database.py +++ b/database.py @@ -95,6 +95,21 @@ class CustomDestinationDisplayName(BaseModel): table_name = "custom_destination_display_names" +class FavouriteDestination(BaseModel): + + id = BigAutoField() + destination_hash = CharField(unique=True) # unique destination hash + display_name = CharField() # custom display name for the destination hash + aspect = CharField() # e.g: nomadnetwork.node + + created_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) + updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) + + # define table name + class Meta: + table_name = "favourite_destinations" + + class LxmfMessage(BaseModel): id = BigAutoField() diff --git a/meshchat.py b/meshchat.py index 7026906..5e08686 100644 --- a/meshchat.py +++ b/meshchat.py @@ -78,6 +78,7 @@ class ReticulumMeshChat: database.Config, database.Announce, database.CustomDestinationDisplayName, + database.FavouriteDestination, database.LxmfMessage, database.LxmfConversationReadState, database.LxmfUserIcon, @@ -1233,6 +1234,79 @@ class ReticulumMeshChat: "announces": announces, }) + # serve favourites + @routes.get("/api/v1/favourites") + async def index(request): + + # get query params + aspect = request.query.get("aspect", None) + + # build favourites database query + query = database.FavouriteDestination.select() + + # filter by provided aspect + if aspect is not None: + query = query.where(database.FavouriteDestination.aspect == aspect) + + # order favourites alphabetically + query_results = query.order_by(database.FavouriteDestination.display_name.asc()) + + # process favourites + favourites = [] + for favourite in query_results: + favourites.append(self.convert_db_favourite_to_dict(favourite)) + + return web.json_response({ + "favourites": favourites, + }) + + # add favourite + @routes.post("/api/v1/favourites/add") + async def index(request): + + # get request data + data = await request.json() + destination_hash = data.get("destination_hash", None) + display_name = data.get("display_name", None) + aspect = data.get("aspect", None) + + # destination hash is required + if destination_hash is None: + return web.json_response({ + "message": "destination_hash is required", + }, status=422) + + # display name is required + if display_name is None: + return web.json_response({ + "message": "display_name is required", + }, status=422) + + # aspect is required + if aspect is None: + return web.json_response({ + "message": "aspect is required", + }, status=422) + + # upsert favourite + self.db_upsert_favourite(destination_hash, display_name, aspect) + return web.json_response({ + "message": "Favourite has been added!", + }) + + # delete favourite + @routes.delete("/api/v1/favourites/{destination_hash}") + async def index(request): + + # get path params + destination_hash = request.match_info.get("destination_hash", "") + + # delete favourite + database.FavouriteDestination.delete().where(database.FavouriteDestination.destination_hash == destination_hash).execute() + return web.json_response({ + "message": "Favourite has been added!", + }) + # propagation node status @routes.get("/api/v1/lxmf/propagation-node/status") async def index(request): @@ -2528,6 +2602,17 @@ class ReticulumMeshChat: "updated_at": announce.updated_at, } + # convert database favourite to a dictionary + def convert_db_favourite_to_dict(self, favourite: database.FavouriteDestination): + return { + "id": favourite.id, + "destination_hash": favourite.destination_hash, + "display_name": favourite.display_name, + "aspect": favourite.aspect, + "created_at": favourite.created_at, + "updated_at": favourite.updated_at, + } + # convert database lxmf message to a dictionary def convert_db_lxmf_message_to_dict(self, db_lxmf_message: database.LxmfMessage): @@ -2738,6 +2823,22 @@ class ReticulumMeshChat: query = query.on_conflict(conflict_target=[database.CustomDestinationDisplayName.destination_hash], update=data) query.execute() + # upserts a custom destination display name to the database + def db_upsert_favourite(self, destination_hash: str, display_name: str, aspect: str): + + # prepare data to insert or update + data = { + "destination_hash": destination_hash, + "display_name": display_name, + "aspect": aspect, + "updated_at": datetime.now(timezone.utc), + } + + # upsert to database + query = database.FavouriteDestination.insert(data) + query = query.on_conflict(conflict_target=[database.FavouriteDestination.destination_hash], update=data) + query.execute() + # upserts lxmf conversation read state to the database def db_mark_lxmf_conversation_as_read(self, destination_hash: str): diff --git a/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/src/frontend/components/nomadnetwork/NomadNetworkPage.vue index ff06cf3..9a4964c 100644 --- a/src/frontend/components/nomadnetwork/NomadNetworkPage.vue +++ b/src/frontend/components/nomadnetwork/NomadNetworkPage.vue @@ -3,6 +3,7 @@ @@ -11,6 +12,29 @@
+ + +
+
+
+
+ + + +
+
+
+
+
+
+ + + +
+
+
+
+
{{ selectedNode.display_name }} @@ -165,10 +189,14 @@ export default { data() { return { + reloadInterval: null, + nodes: {}, selectedNode: null, selectedNodePath: null, + favourites: [], + isLoadingNodePage: false, isShowingNodePageSource: false, defaultNodePagePath: "/page/index.mu", @@ -191,6 +219,8 @@ export default { }, beforeUnmount() { + clearInterval(this.reloadInterval); + // stop listening for websocket messages WebSocketConnection.off("message", this.onWebsocketMessage); @@ -215,8 +245,14 @@ export default { })(); } + this.getFavourites(); this.getNomadnetworkNodeAnnounces(); + // update info every few seconds + this.reloadInterval = setInterval(() => { + this.getFavourites(); + }, 5000); + }, methods: { onElementClick(event) { @@ -322,6 +358,54 @@ export default { onDestinationPathClick: function(path) { DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`); }, + async getFavourites() { + try { + const response = await window.axios.get("/api/v1/favourites", { + params: { + aspect: "nomadnetwork.node", + }, + }); + this.favourites = response.data.favourites; + } catch(e) { + // do nothing if failed to load favourites + console.log(e); + } + }, + isFavourite(destinationHash) { + return this.favourites.find((favourite) => { + return favourite.destination_hash === destinationHash; + }) != null; + }, + async addFavourite(node) { + + // add to favourites + try { + await window.axios.post("/api/v1/favourites/add", { + destination_hash: node.destination_hash, + display_name: node.display_name, + aspect: "nomadnetwork.node", + }); + } catch(e) { + console.log(e); + } + + // update favourites + this.getFavourites(); + + }, + async removeFavourite(node) { + + // remove from favourites + try { + await window.axios.delete(`/api/v1/favourites/${node.destination_hash}`); + } catch(e) { + console.log(e); + } + + // update favourites + this.getFavourites(); + + }, async getNomadnetworkNodeAnnounces() { try { diff --git a/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue index 48561c8..a24ec30 100644 --- a/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue +++ b/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue @@ -1,6 +1,67 @@ @@ -71,10 +133,13 @@ export default { components: {MaterialDesignIcon}, props: { nodes: Object, + favourites: Array, selectedDestinationHash: String, }, data() { return { + tab: "favourites", + favouritesSearchTerm: "", nodesSearchTerm: "", }; }, @@ -82,9 +147,15 @@ export default { onNodeClick(node) { this.$emit("node-click", node); }, + onFavouriteClick(favourite) { + this.onNodeClick(favourite); + }, formatTimeAgo: function(datetimeString) { return Utils.formatTimeAgo(datetimeString); }, + formatDestinationHash: function(destinationHash) { + return Utils.formatDestinationHash(destinationHash); + }, }, computed: { nodesCount() { @@ -107,6 +178,15 @@ export default { return matchesDisplayName || matchesDestinationHash; }); }, + searchedFavourites() { + return this.favourites.filter((favourite) => { + const search = this.favouritesSearchTerm.toLowerCase(); + const matchesDisplayName = favourite.display_name.toLowerCase().includes(search); + const matchesCustomDisplayName = favourite.custom_display_name?.toLowerCase()?.includes(search) === true; + const matchesDestinationHash = favourite.destination_hash.toLowerCase().includes(search); + return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash; + }); + }, }, } diff --git a/src/frontend/js/Utils.js b/src/frontend/js/Utils.js index 9362eca..74fcf6a 100644 --- a/src/frontend/js/Utils.js +++ b/src/frontend/js/Utils.js @@ -2,6 +2,13 @@ import moment from "moment"; class Utils { + static formatDestinationHash(destinationHashHex) { + const bytesPerSide = 4; + const leftSide = destinationHashHex.substring(0, bytesPerSide * 2); + const rightSide = destinationHashHex.substring(destinationHashHex.length - bytesPerSide * 2); + return `<${leftSide}...${rightSide}>` + } + static formatBytes(bytes) { if(bytes === 0){ From 64adad27f886e6dce8f91c6943c6dd6fedaa1f0c Mon Sep 17 00:00:00 2001 From: liamcottle Date: Fri, 25 Jul 2025 23:22:56 +1200 Subject: [PATCH 03/31] limit nomadnetwork announces list to 500 recent nodes --- src/frontend/components/nomadnetwork/NomadNetworkPage.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/src/frontend/components/nomadnetwork/NomadNetworkPage.vue index 9a4964c..2229636 100644 --- a/src/frontend/components/nomadnetwork/NomadNetworkPage.vue +++ b/src/frontend/components/nomadnetwork/NomadNetworkPage.vue @@ -413,6 +413,7 @@ export default { const response = await window.axios.get(`/api/v1/announces`, { params: { aspect: "nomadnetwork.node", + limit: 500, // limit ui to showing 500 latest announces }, }); From 9031c1a3d7009456ea156e447f90e1a5dd40983e Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sun, 27 Jul 2025 20:17:08 +1200 Subject: [PATCH 04/31] add dropdown menu to nomadnetwork favourites list to rename and remove --- meshchat.py | 19 ++++++++ .../nomadnetwork/NomadNetworkPage.vue | 38 +++++++++++++++- .../nomadnetwork/NomadNetworkSidebar.vue | 43 ++++++++++++++++++- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/meshchat.py b/meshchat.py index 5e08686..a6d92db 100644 --- a/meshchat.py +++ b/meshchat.py @@ -1294,6 +1294,25 @@ class ReticulumMeshChat: "message": "Favourite has been added!", }) + # rename favourite + @routes.post("/api/v1/favourites/{destination_hash}/rename") + async def index(request): + + # get path params + destination_hash = request.match_info.get("destination_hash", "") + + # get request data + data = await request.json() + display_name = data.get("display_name") + + # update display name if provided + if len(display_name) > 0: + database.FavouriteDestination.update(display_name=display_name).where(database.FavouriteDestination.destination_hash == destination_hash).execute() + + return web.json_response({ + "message": "Favourite has been renamed", + }) + # delete favourite @routes.delete("/api/v1/favourites/{destination_hash}") async def index(request): diff --git a/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/src/frontend/components/nomadnetwork/NomadNetworkPage.vue index 2229636..6958a86 100644 --- a/src/frontend/components/nomadnetwork/NomadNetworkPage.vue +++ b/src/frontend/components/nomadnetwork/NomadNetworkPage.vue @@ -5,7 +5,9 @@ :nodes="nodes" :favourites="favourites" :selected-destination-hash="selectedNode?.destination_hash" - @node-click="onNodeClick"/> + @node-click="onNodeClick" + @rename-favourite="onRenameFavourite" + @remove-favourite="onRemoveFavourite"/>
@@ -835,6 +837,40 @@ export default { // load default node page this.loadNodePage(node.destination_hash, this.defaultNodePagePath); + }, + async onRenameFavourite(favourite) { + + // ask user for new display name + const displayName = await DialogUtils.prompt("Rename this favourite"); + if(displayName == null){ + return; + } + + try { + + // rename on server + await axios.post(`/api/v1/favourites/${favourite.destination_hash}/rename`, { + display_name: displayName, + }); + + // reload favourites + await this.getFavourites(); + + } catch(e) { + console.log(e); + DialogUtils.alert("Failed to rename favourite"); + } + + }, + onRemoveFavourite: function(favourite) { + + // ask user to confirm + if(!confirm("Are you sure you want to remove this favourite?")){ + return; + } + + this.removeFavourite(favourite); + }, onCloseNodeViewer: function() { diff --git a/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue index a24ec30..62812e5 100644 --- a/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue +++ b/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue @@ -30,6 +30,38 @@
{{ favourite.display_name }}
{{ formatDestinationHash(favourite.destination_hash) }}
+
+ + + + +
@@ -127,10 +159,13 @@ import Utils from "../../js/Utils"; import MaterialDesignIcon from "../MaterialDesignIcon.vue"; +import DropDownMenu from "../DropDownMenu.vue"; +import IconButton from "../IconButton.vue"; +import DropDownMenuItem from "../DropDownMenuItem.vue"; export default { name: 'NomadNetworkSidebar', - components: {MaterialDesignIcon}, + components: {DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon}, props: { nodes: Object, favourites: Array, @@ -150,6 +185,12 @@ export default { onFavouriteClick(favourite) { this.onNodeClick(favourite); }, + onRenameFavourite(favourite) { + this.$emit("rename-favourite", favourite); + }, + onRemoveFavourite(favourite) { + this.$emit("remove-favourite", favourite); + }, formatTimeAgo: function(datetimeString) { return Utils.formatTimeAgo(datetimeString); }, From 0c0f059ec444db9514ce9e889427f6bd7cd2b46d Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sun, 27 Jul 2025 20:18:38 +1200 Subject: [PATCH 05/31] 2.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d3477e1..c31e9de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "reticulum-meshchat", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reticulum-meshchat", - "version": "2.1.0", + "version": "2.2.0", "license": "MIT", "dependencies": { "@mdi/js": "^7.4.47", diff --git a/package.json b/package.json index 7b71765..9f66a11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reticulum-meshchat", - "version": "2.1.0", + "version": "2.2.0", "description": "", "main": "electron/main.js", "scripts": { From 094f6cb5ec12914f0fe45275f4e2a81613af3679 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sun, 27 Jul 2025 21:45:27 +1200 Subject: [PATCH 06/31] added custom confirm dialog as js confirm in electron on windows causes all text fields to be disabled --- electron/main.js | 21 +++++++++++++++++++ electron/preload.js | 5 +++++ src/frontend/components/App.vue | 4 ++-- src/frontend/components/call/CallPage.vue | 7 ++++--- .../components/interfaces/InterfacesPage.vue | 2 +- .../messages/ConversationDropDownMenu.vue | 2 +- .../messages/ConversationViewer.vue | 14 ++++++------- .../nomadnetwork/NomadNetworkPage.vue | 6 +++--- .../components/profile/ProfileIconPage.vue | 4 ++-- src/frontend/js/DialogUtils.js | 10 +++++++++ 10 files changed, 56 insertions(+), 19 deletions(-) diff --git a/electron/main.js b/electron/main.js index 1c70182..bdaa0cc 100644 --- a/electron/main.js +++ b/electron/main.js @@ -22,6 +22,27 @@ ipcMain.handle('alert', async(event, message) => { }); }); +// add support for showing a confirm window via ipc +ipcMain.handle('confirm', async(event, message) => { + + // show confirm dialog + const result = await dialog.showMessageBox(mainWindow, { + type: "question", + title: "Confirm", + message: message, + cancelId: 0, // esc key should press cancel button + defaultId: 1, // enter key should press ok button + buttons: [ + "Cancel", // 0 + "OK", // 1 + ], + }); + + // check if user clicked OK + return result.response === 1; + +}); + // add support for showing a prompt window via ipc ipcMain.handle('prompt', async(event, message) => { return await electronPrompt({ diff --git a/electron/preload.js b/electron/preload.js index 8abc460..5465420 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -15,6 +15,11 @@ contextBridge.exposeInMainWorld('electron', { return await ipcRenderer.invoke('alert', message); }, + // show a confirm dialog in electron browser window, this fixes a bug where confirm breaks input fields on windows + confirm: async function(message) { + return await ipcRenderer.invoke('confirm', message); + }, + // add support for using "prompt" in electron browser window prompt: async function(message) { return await ipcRenderer.invoke('prompt', message); diff --git a/src/frontend/components/App.vue b/src/frontend/components/App.vue index ff92964..3e69308 100644 --- a/src/frontend/components/App.vue +++ b/src/frontend/components/App.vue @@ -448,7 +448,7 @@ export default { // ask to stop syncing if already syncing if(this.isSyncingPropagationNode){ - if(confirm("Are you sure you want to stop syncing?")){ + if(await DialogUtils.confirm("Are you sure you want to stop syncing?")){ await this.stopSyncingPropagationNode(); } return; @@ -529,7 +529,7 @@ export default { async hangupAllCalls() { // confirm user wants to hang up calls - if(!confirm("Are you sure you want to hang up all incoming and outgoing calls?")){ + if(!await DialogUtils.confirm("Are you sure you want to hang up all incoming and outgoing calls?")){ return; } diff --git a/src/frontend/components/call/CallPage.vue b/src/frontend/components/call/CallPage.vue index 509c3bc..cc74d2c 100644 --- a/src/frontend/components/call/CallPage.vue +++ b/src/frontend/components/call/CallPage.vue @@ -259,6 +259,7 @@