mirror of
https://github.com/liamcottle/reticulum-meshchat.git
synced 2025-12-22 08:27:15 +00:00
implement favourites system for nomadnetwork nodes
This commit is contained in:
15
database.py
15
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()
|
||||
|
||||
101
meshchat.py
101
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):
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<!-- nomadnetwork sidebar -->
|
||||
<NomadNetworkSidebar
|
||||
:nodes="nodes"
|
||||
:favourites="favourites"
|
||||
:selected-destination-hash="selectedNode?.destination_hash"
|
||||
@node-click="onNodeClick"/>
|
||||
|
||||
@@ -11,6 +12,29 @@
|
||||
<div v-if="selectedNode" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
|
||||
<!-- header -->
|
||||
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
|
||||
|
||||
<!-- favourite button -->
|
||||
<div class="my-auto mr-2">
|
||||
<div v-if="isFavourite(selectedNode.destination_hash)" @click="removeFavourite(selectedNode)" class="cursor-pointer">
|
||||
<div class="flex text-yellow-500 dark:text-yellow-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else @click="addFavourite(selectedNode)" class="cursor-pointer">
|
||||
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- node info -->
|
||||
<div class="my-auto dark:text-gray-100">
|
||||
<span class="font-semibold">{{ selectedNode.display_name }}</span>
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-80 min-w-80">
|
||||
<div class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
|
||||
|
||||
<!-- tabs -->
|
||||
<div class="bg-white dark:bg-zinc-950 border-b border-r border-gray-200 dark:border-zinc-700">
|
||||
<div class="-mb-px flex">
|
||||
<div @click="tab = 'favourites'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'favourites' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Favourites</div>
|
||||
<div @click="tab = 'announces'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'announces' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Announces</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- favourites -->
|
||||
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden">
|
||||
|
||||
<!-- search -->
|
||||
<div v-if="favourites.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
|
||||
<input v-model="favouritesSearchTerm" type="text" :placeholder="`Search ${favourites.length} Favourites...`" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
</div>
|
||||
|
||||
<!-- peers -->
|
||||
<div class="flex h-full overflow-y-auto">
|
||||
<div v-if="searchedFavourites.length > 0" class="w-full">
|
||||
<div @click="onFavouriteClick(favourite)" v-for="favourite of searchedFavourites" class="flex cursor-pointer p-2 border-l-2" :class="[ favourite.destination_hash === selectedDestinationHash ? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400' : 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600' ]">
|
||||
<div class="my-auto mr-2">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 p-2 rounded">
|
||||
<MaterialDesignIcon icon-name="server-network-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ favourite.display_name }}</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatDestinationHash(favourite.destination_hash) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mx-auto my-auto text-center leading-5">
|
||||
|
||||
<!-- no favourites at all -->
|
||||
<div v-if="favourites.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div class="mx-auto mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold">No Favourites</div>
|
||||
<div>Discover nodes on the Announces tab.</div>
|
||||
</div>
|
||||
|
||||
<!-- is searching, but no results -->
|
||||
<div v-if="favouritesSearchTerm !== '' && favourites.length > 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div class="mx-auto mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold">No Search Results</div>
|
||||
<div>Your search didn't match any Favourites!</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- announces -->
|
||||
<div v-if="tab === 'announces'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
|
||||
<!-- search -->
|
||||
<div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800">
|
||||
<input
|
||||
@@ -58,6 +119,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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){
|
||||
|
||||
Reference in New Issue
Block a user