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){