Compare commits
38 Commits
v1.22.0
...
docker-imp
| Author | SHA1 | Date | |
|---|---|---|---|
| f0edb4bc8d | |||
|
|
002360399c | ||
|
|
c9f4ef64c1 | ||
|
|
ffe2cb884d | ||
|
|
d6847d262a | ||
|
|
65df111b87 | ||
|
|
747236ae8b | ||
|
|
4e55006084 | ||
|
|
dcaffe2594 | ||
|
|
094f6cb5ec | ||
|
|
0c0f059ec4 | ||
|
|
9031c1a3d7 | ||
|
|
64adad27f8 | ||
|
|
4734e62468 | ||
|
|
37cc6aa158 | ||
|
|
f3bf0abd84 | ||
|
|
90445467e1 | ||
|
|
51bdd35f01 | ||
|
|
817d5b5e59 | ||
|
|
a094a741a8 | ||
|
|
24acbaf223 | ||
|
|
0bb171a81b | ||
|
|
b5a54dd120 | ||
|
|
86cfddce52 | ||
|
|
97071c7edb | ||
|
|
a58f73357a | ||
|
|
6b3639dcd2 | ||
|
|
47a84fc110 | ||
|
|
588780d632 | ||
|
|
5b783399f8 | ||
|
|
df533fb1bf | ||
|
|
e757a2f022 | ||
|
|
ce56c205c6 | ||
|
|
66b619c398 | ||
|
|
458a387517 | ||
|
|
e97352713d | ||
|
|
07a41215be | ||
|
|
e9a9e9f831 |
55
.dockerignore
Normal file
55
.dockerignore
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
|
donate.md
|
||||||
|
screenshots/
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.github/
|
||||||
|
electron/
|
||||||
|
|
||||||
|
# Build artifacts and cache
|
||||||
|
public/
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
14
Dockerfile
14
Dockerfile
@@ -1,5 +1,11 @@
|
|||||||
|
# Build arguments
|
||||||
|
ARG NODE_VERSION=20
|
||||||
|
ARG NODE_ALPINE_SHA256=sha256:6a91081a440be0b57336fbc4ee87f3dab1a2fd6f80cdb355dcf960e13bda3b59
|
||||||
|
ARG PYTHON_VERSION=3.11
|
||||||
|
ARG PYTHON_ALPINE_SHA256=sha256:822ceb965f026bc47ee667e50a44309d2d81087780bbbf64f2005521781a3621
|
||||||
|
|
||||||
# Build the frontend
|
# Build the frontend
|
||||||
FROM node:20-bookworm-slim AS build-frontend
|
FROM node:${NODE_VERSION}-alpine@${NODE_ALPINE_SHA256} AS build-frontend
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -13,13 +19,15 @@ RUN npm install --omit=dev && \
|
|||||||
npm run build-frontend
|
npm run build-frontend
|
||||||
|
|
||||||
# Main app build
|
# Main app build
|
||||||
FROM python:3.11-bookworm
|
FROM python:${PYTHON_VERSION}-alpine@${PYTHON_ALPINE_SHA256}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python deps
|
# Install Python deps
|
||||||
COPY ./requirements.txt .
|
COPY ./requirements.txt .
|
||||||
RUN pip install -r requirements.txt
|
RUN apk add --no-cache --virtual .build-deps gcc musl-dev && \
|
||||||
|
pip install -r requirements.txt && \
|
||||||
|
apk del .build-deps
|
||||||
|
|
||||||
# Copy prebuilt frontend
|
# Copy prebuilt frontend
|
||||||
COPY --from=build-frontend /src/public public
|
COPY --from=build-frontend /src/public public
|
||||||
|
|||||||
15
database.py
15
database.py
@@ -95,6 +95,21 @@ class CustomDestinationDisplayName(BaseModel):
|
|||||||
table_name = "custom_destination_display_names"
|
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):
|
class LxmfMessage(BaseModel):
|
||||||
|
|
||||||
id = BigAutoField()
|
id = BigAutoField()
|
||||||
|
|||||||
@@ -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
|
// add support for showing a prompt window via ipc
|
||||||
ipcMain.handle('prompt', async(event, message) => {
|
ipcMain.handle('prompt', async(event, message) => {
|
||||||
return await electronPrompt({
|
return await electronPrompt({
|
||||||
@@ -99,7 +120,8 @@ function getDefaultReticulumConfigDir() {
|
|||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
|
||||||
// get arguments passed to application, and remove the provided application path
|
// get arguments passed to application, and remove the provided application path
|
||||||
const userProvidedArguments = process.argv.slice(1);
|
const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"];
|
||||||
|
const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg));
|
||||||
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
|
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
|
||||||
|
|
||||||
if(!shouldLaunchHeadless){
|
if(!shouldLaunchHeadless){
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ contextBridge.exposeInMainWorld('electron', {
|
|||||||
return await ipcRenderer.invoke('alert', message);
|
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
|
// add support for using "prompt" in electron browser window
|
||||||
prompt: async function(message) {
|
prompt: async function(message) {
|
||||||
return await ipcRenderer.invoke('prompt', message);
|
return await ipcRenderer.invoke('prompt', message);
|
||||||
|
|||||||
218
meshchat.py
218
meshchat.py
@@ -1,8 +1,10 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -77,6 +79,7 @@ class ReticulumMeshChat:
|
|||||||
database.Config,
|
database.Config,
|
||||||
database.Announce,
|
database.Announce,
|
||||||
database.CustomDestinationDisplayName,
|
database.CustomDestinationDisplayName,
|
||||||
|
database.FavouriteDestination,
|
||||||
database.LxmfMessage,
|
database.LxmfMessage,
|
||||||
database.LxmfConversationReadState,
|
database.LxmfConversationReadState,
|
||||||
database.LxmfUserIcon,
|
database.LxmfUserIcon,
|
||||||
@@ -942,6 +945,7 @@ class ReticulumMeshChat:
|
|||||||
"version": self.get_app_version(),
|
"version": self.get_app_version(),
|
||||||
"lxmf_version": LXMF.__version__,
|
"lxmf_version": LXMF.__version__,
|
||||||
"rns_version": RNS.__version__,
|
"rns_version": RNS.__version__,
|
||||||
|
"python_version": platform.python_version(),
|
||||||
"storage_path": self.storage_path,
|
"storage_path": self.storage_path,
|
||||||
"database_path": self.database_path,
|
"database_path": self.database_path,
|
||||||
"database_file_size": os.path.getsize(self.database_path),
|
"database_file_size": os.path.getsize(self.database_path),
|
||||||
@@ -1232,6 +1236,98 @@ class ReticulumMeshChat:
|
|||||||
"announces": announces,
|
"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!",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 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):
|
||||||
|
|
||||||
|
# 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
|
# propagation node status
|
||||||
@routes.get("/api/v1/lxmf/propagation-node/status")
|
@routes.get("/api/v1/lxmf/propagation-node/status")
|
||||||
async def index(request):
|
async def index(request):
|
||||||
@@ -1757,6 +1853,30 @@ class ReticulumMeshChat:
|
|||||||
"lxmf_message": lxmf_message,
|
"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
|
# delete lxmf message
|
||||||
@routes.delete("/api/v1/lxmf-messages/{hash}")
|
@routes.delete("/api/v1/lxmf-messages/{hash}")
|
||||||
async def index(request):
|
async def index(request):
|
||||||
@@ -1920,6 +2040,9 @@ class ReticulumMeshChat:
|
|||||||
# called when web app has started
|
# called when web app has started
|
||||||
async def on_startup(app):
|
async def on_startup(app):
|
||||||
|
|
||||||
|
# remember main event loop
|
||||||
|
AsyncUtils.set_main_loop(asyncio.get_event_loop())
|
||||||
|
|
||||||
# auto launch web browser
|
# auto launch web browser
|
||||||
if launch_browser:
|
if launch_browser:
|
||||||
try:
|
try:
|
||||||
@@ -2143,11 +2266,9 @@ class ReticulumMeshChat:
|
|||||||
},
|
},
|
||||||
})))
|
})))
|
||||||
|
|
||||||
# todo: handle file download progress
|
|
||||||
|
|
||||||
# download the file
|
# download the file
|
||||||
downloader = NomadnetFileDownloader(destination_hash, file_path, on_file_download_success, on_file_download_failure, on_file_download_progress)
|
downloader = NomadnetFileDownloader(destination_hash, file_path, on_file_download_success, on_file_download_failure, on_file_download_progress)
|
||||||
await downloader.download()
|
AsyncUtils.run_async(downloader.download())
|
||||||
|
|
||||||
# handle downloading a page from a nomadnet node
|
# handle downloading a page from a nomadnet node
|
||||||
elif _type == "nomadnet.page.download":
|
elif _type == "nomadnet.page.download":
|
||||||
@@ -2217,11 +2338,9 @@ class ReticulumMeshChat:
|
|||||||
},
|
},
|
||||||
})))
|
})))
|
||||||
|
|
||||||
# todo: handle page download progress
|
|
||||||
|
|
||||||
# download the page
|
# download the page
|
||||||
downloader = NomadnetPageDownloader(destination_hash, page_path_to_download, combined_data, on_page_download_success, on_page_download_failure, on_page_download_progress)
|
downloader = NomadnetPageDownloader(destination_hash, page_path_to_download, combined_data, on_page_download_success, on_page_download_failure, on_page_download_progress)
|
||||||
await downloader.download()
|
AsyncUtils.run_async(downloader.download())
|
||||||
|
|
||||||
# unhandled type
|
# unhandled type
|
||||||
else:
|
else:
|
||||||
@@ -2507,6 +2626,17 @@ class ReticulumMeshChat:
|
|||||||
"updated_at": announce.updated_at,
|
"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
|
# convert database lxmf message to a dictionary
|
||||||
def convert_db_lxmf_message_to_dict(self, db_lxmf_message: database.LxmfMessage):
|
def convert_db_lxmf_message_to_dict(self, db_lxmf_message: database.LxmfMessage):
|
||||||
|
|
||||||
@@ -2717,6 +2847,22 @@ class ReticulumMeshChat:
|
|||||||
query = query.on_conflict(conflict_target=[database.CustomDestinationDisplayName.destination_hash], update=data)
|
query = query.on_conflict(conflict_target=[database.CustomDestinationDisplayName.destination_hash], update=data)
|
||||||
query.execute()
|
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
|
# upserts lxmf conversation read state to the database
|
||||||
def db_mark_lxmf_conversation_as_read(self, destination_hash: str):
|
def db_mark_lxmf_conversation_as_read(self, destination_hash: str):
|
||||||
|
|
||||||
@@ -3271,7 +3417,7 @@ class Config:
|
|||||||
nomadnet_cached_links = {}
|
nomadnet_cached_links = {}
|
||||||
class NomadnetDownloader:
|
class NomadnetDownloader:
|
||||||
|
|
||||||
def __init__(self, destination_hash: bytes, path: str, data: str|None, on_download_success: Callable[[bytes], None], on_download_failure: Callable[[str], None], on_progress_update: Callable[[float], None], timeout: int|None = None):
|
def __init__(self, destination_hash: bytes, path: str, data: str|None, on_download_success: Callable[[RNS.RequestReceipt], None], on_download_failure: Callable[[str], None], on_progress_update: Callable[[float], None], timeout: int|None = None):
|
||||||
self.app_name = "nomadnetwork"
|
self.app_name = "nomadnetwork"
|
||||||
self.aspects = "node"
|
self.aspects = "node"
|
||||||
self.destination_hash = destination_hash
|
self.destination_hash = destination_hash
|
||||||
@@ -3353,8 +3499,8 @@ class NomadnetDownloader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# handle successful download
|
# handle successful download
|
||||||
def on_response(self, request_receipt):
|
def on_response(self, request_receipt: RNS.RequestReceipt):
|
||||||
self.on_download_success(request_receipt.response)
|
self.on_download_success(request_receipt)
|
||||||
|
|
||||||
# handle failure
|
# handle failure
|
||||||
def on_failed(self, request_receipt=None):
|
def on_failed(self, request_receipt=None):
|
||||||
@@ -3373,8 +3519,8 @@ class NomadnetPageDownloader(NomadnetDownloader):
|
|||||||
super().__init__(destination_hash, page_path, data, self.on_download_success, self.on_download_failure, on_progress_update, timeout)
|
super().__init__(destination_hash, page_path, data, self.on_download_success, self.on_download_failure, on_progress_update, timeout)
|
||||||
|
|
||||||
# page download was successful, decode the response and send to provided callback
|
# page download was successful, decode the response and send to provided callback
|
||||||
def on_download_success(self, response_bytes):
|
def on_download_success(self, request_receipt: RNS.RequestReceipt):
|
||||||
micron_markup_response = response_bytes.decode("utf-8")
|
micron_markup_response = request_receipt.response.decode("utf-8")
|
||||||
self.on_page_download_success(micron_markup_response)
|
self.on_page_download_success(micron_markup_response)
|
||||||
|
|
||||||
# page download failed, send error to provided callback
|
# page download failed, send error to provided callback
|
||||||
@@ -3390,10 +3536,52 @@ class NomadnetFileDownloader(NomadnetDownloader):
|
|||||||
super().__init__(destination_hash, page_path, None, self.on_download_success, self.on_download_failure, on_progress_update, timeout)
|
super().__init__(destination_hash, page_path, None, self.on_download_success, self.on_download_failure, on_progress_update, timeout)
|
||||||
|
|
||||||
# file download was successful, decode the response and send to provided callback
|
# file download was successful, decode the response and send to provided callback
|
||||||
def on_download_success(self, response):
|
def on_download_success(self, request_receipt: RNS.RequestReceipt):
|
||||||
file_name: str = response[0]
|
|
||||||
file_data: bytes = response[1]
|
# get response
|
||||||
self.on_file_download_success(file_name, file_data)
|
response = request_receipt.response
|
||||||
|
|
||||||
|
# handle buffered reader response
|
||||||
|
if isinstance(response, io.BufferedReader):
|
||||||
|
|
||||||
|
# get file name from metadata
|
||||||
|
file_name = "downloaded_file"
|
||||||
|
metadata = request_receipt.metadata
|
||||||
|
if metadata is not None and "name" in metadata:
|
||||||
|
file_path = metadata["name"].decode("utf-8")
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
|
||||||
|
# get file data
|
||||||
|
file_data: bytes = response.read()
|
||||||
|
|
||||||
|
self.on_file_download_success(file_name, file_data)
|
||||||
|
return
|
||||||
|
|
||||||
|
# check for list response with bytes in position 0, and metadata dict in position 1
|
||||||
|
# e.g: [file_bytes, {name: "filename.ext"}]
|
||||||
|
if isinstance(response, list) and isinstance(response[1], dict):
|
||||||
|
|
||||||
|
file_data: bytes = response[0]
|
||||||
|
metadata: dict = response[1]
|
||||||
|
|
||||||
|
# get file name from metadata
|
||||||
|
file_name = "downloaded_file"
|
||||||
|
if metadata is not None and "name" in metadata:
|
||||||
|
file_path = metadata["name"].decode("utf-8")
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
|
||||||
|
self.on_file_download_success(file_name, file_data)
|
||||||
|
return
|
||||||
|
|
||||||
|
# try using original response format
|
||||||
|
# unsure if this is actually used anymore now that a buffered reader is provided
|
||||||
|
# have left here just in case...
|
||||||
|
try:
|
||||||
|
file_name: str = response[0]
|
||||||
|
file_data: bytes = response[1]
|
||||||
|
self.on_file_download_success(file_name, file_data)
|
||||||
|
except:
|
||||||
|
self.on_download_failure("unsupported_response")
|
||||||
|
|
||||||
# page download failed, send error to provided callback
|
# page download failed, send error to provided callback
|
||||||
def on_download_failure(self, failure_reason):
|
def on_download_failure(self, failure_reason):
|
||||||
|
|||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "reticulum-meshchat",
|
"name": "reticulum-meshchat",
|
||||||
"version": "1.21.0",
|
"version": "2.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "reticulum-meshchat",
|
"name": "reticulum-meshchat",
|
||||||
"version": "1.21.0",
|
"version": "2.2.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.10.0",
|
||||||
"click-outside-vue3": "^4.0.1",
|
"click-outside-vue3": "^4.0.1",
|
||||||
"compressorjs": "^1.2.1",
|
"compressorjs": "^1.2.1",
|
||||||
"electron-prompt": "^1.7.0",
|
"electron-prompt": "^1.7.0",
|
||||||
"micron-parser": "^1.0.1",
|
"micron-parser": "^1.0.2",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
@@ -1915,9 +1915,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.7.9",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||||
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
|
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.0",
|
||||||
@@ -4184,9 +4184,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micron-parser": {
|
"node_modules/micron-parser": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/micron-parser/-/micron-parser-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/micron-parser/-/micron-parser-1.0.2.tgz",
|
||||||
"integrity": "sha512-fh3BWIoowiYtSnXg7O8cYnF+qZhGmxTU0S1fwVGtSXb6j04MSfe+8R0KzOgIn2eAij682QGqoeaz5mM2+kCl6Q==",
|
"integrity": "sha512-lYrEolylOUXeSISYrPRW/ZZAH1dpZRyTJ0VzQIA4cWJy0yNCXUUs+ujuAwV2OYlAPH8tCE1Z22+zg04Ilp/JWg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "*"
|
"dompurify": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "reticulum-meshchat",
|
"name": "reticulum-meshchat",
|
||||||
"version": "1.21.0",
|
"version": "2.2.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "electron/main.js",
|
"main": "electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -98,11 +98,11 @@
|
|||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.10.0",
|
||||||
"click-outside-vue3": "^4.0.1",
|
"click-outside-vue3": "^4.0.1",
|
||||||
"compressorjs": "^1.2.1",
|
"compressorjs": "^1.2.1",
|
||||||
"electron-prompt": "^1.7.0",
|
"electron-prompt": "^1.7.0",
|
||||||
"micron-parser": "^1.0.1",
|
"micron-parser": "^1.0.2",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
aiohttp>=3.9.5
|
aiohttp>=3.12.14
|
||||||
cx_freeze>=7.0.0
|
cx_freeze>=7.0.0
|
||||||
lxmf>=0.6.3
|
lxmf>=0.8.0
|
||||||
peewee>=3.17.3
|
peewee>=3.18.1
|
||||||
rns>=0.9.3
|
rns>=1.0.0
|
||||||
websockets>=14.2
|
websockets>=14.2
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from typing import Coroutine
|
||||||
|
|
||||||
|
|
||||||
class AsyncUtils:
|
class AsyncUtils:
|
||||||
|
|
||||||
# this method allows running the provided async coroutine from within a sync function
|
# remember main loop
|
||||||
# it will run the async function on the existing event loop if available, otherwise it will start a new event loop
|
main_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def run_async(coroutine):
|
def set_main_loop(loop: asyncio.AbstractEventLoop):
|
||||||
|
AsyncUtils.main_loop = loop
|
||||||
|
|
||||||
# attempt to get existing event loop
|
# this method allows running the provided async coroutine from within a sync function
|
||||||
existing_event_loop = None
|
# it will run the async function on the main event loop if possible, otherwise it logs a warning
|
||||||
try:
|
@staticmethod
|
||||||
existing_event_loop = asyncio.get_running_loop()
|
def run_async(coroutine: Coroutine):
|
||||||
except RuntimeError:
|
|
||||||
# 'RuntimeError: no running event loop'
|
|
||||||
pass
|
|
||||||
|
|
||||||
# if there is an existing event loop running, submit the coroutine to that loop
|
# run provided coroutine on main event loop, ensuring thread safety
|
||||||
if existing_event_loop and existing_event_loop.is_running():
|
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
|
||||||
existing_event_loop.create_task(coroutine)
|
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
|
||||||
return
|
return
|
||||||
|
|
||||||
# otherwise start a new event loop to run the coroutine
|
# main event loop not running...
|
||||||
asyncio.run(coroutine)
|
print("WARNING: Main event loop not available. Could not schedule task.")
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ export default {
|
|||||||
|
|
||||||
// ask to stop syncing if already syncing
|
// ask to stop syncing if already syncing
|
||||||
if(this.isSyncingPropagationNode){
|
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();
|
await this.stopSyncingPropagationNode();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -529,7 +529,7 @@ export default {
|
|||||||
async hangupAllCalls() {
|
async hangupAllCalls() {
|
||||||
|
|
||||||
// confirm user wants to hang up calls
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="mr-auto">
|
<div class="mr-auto">
|
||||||
<div>Versions</div>
|
<div>Versions</div>
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
||||||
MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }}
|
MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }} • Python v{{ appInfo.python_version }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden sm:block mx-2 my-auto">
|
<div class="hidden sm:block mx-2 my-auto">
|
||||||
|
|||||||
@@ -259,6 +259,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import protobuf from "protobufjs";
|
import protobuf from "protobufjs";
|
||||||
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
export default {
|
export default {
|
||||||
name: 'CallPage',
|
name: 'CallPage',
|
||||||
data() {
|
data() {
|
||||||
@@ -488,7 +489,7 @@ export default {
|
|||||||
async hangupCall(callHash) {
|
async hangupCall(callHash) {
|
||||||
|
|
||||||
// confirm user wants to hang up call
|
// confirm user wants to hang up call
|
||||||
if(!confirm("Are you sure you want to hang up this call?")){
|
if(!await DialogUtils.confirm("Are you sure you want to hang up this call?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,7 +682,7 @@ export default {
|
|||||||
async deleteCall(callHash) {
|
async deleteCall(callHash) {
|
||||||
|
|
||||||
// confirm user wants to delete call
|
// confirm user wants to delete call
|
||||||
if(!confirm("Are you sure you want to delete this call?")){
|
if(!await DialogUtils.confirm("Are you sure you want to delete this call?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +702,7 @@ export default {
|
|||||||
async clearCallHistory() {
|
async clearCallHistory() {
|
||||||
|
|
||||||
// confirm user wants to clear call history
|
// confirm user wants to clear call history
|
||||||
if(!confirm("Are you sure you want to clear your call history?")){
|
if(!await DialogUtils.confirm("Are you sure you want to clear your call history?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -690,7 +690,7 @@
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
v-model="newInterfaceAirtimeLimitShort"
|
v-model="newInterfaceAirtimeLimitShort"
|
||||||
placeholder="Enter short airtime limit (seconds)"
|
placeholder="Enter short airtime limit (% of a rolling 15 seconds window)"
|
||||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -699,7 +699,7 @@
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
v-model="newInterfaceAirtimeLimitLong"
|
v-model="newInterfaceAirtimeLimitLong"
|
||||||
placeholder="Enter long airtime limit (seconds)"
|
placeholder="Enter long airtime limit (% of a rolling 60 minutes window)"
|
||||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export default {
|
|||||||
async deleteInterface(interfaceName) {
|
async deleteInterface(interfaceName) {
|
||||||
|
|
||||||
// ask user to confirm deleting conversation history
|
// ask user to confirm deleting conversation history
|
||||||
if(!confirm("Are you sure you want to delete this interface? This can not be undone!")){
|
if(!await DialogUtils.confirm("Are you sure you want to delete this interface? This can not be undone!")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default {
|
|||||||
async onDeleteMessageHistory() {
|
async onDeleteMessageHistory() {
|
||||||
|
|
||||||
// ask user to confirm deleting conversation history
|
// ask user to confirm deleting conversation history
|
||||||
if(!confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
|
if(!await DialogUtils.confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -997,7 +997,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
// ask user to confirm deleting message
|
// ask user to confirm deleting message
|
||||||
if(shouldConfirm && !confirm("Are you sure you want to delete this message? This can not be undone!")){
|
if(shouldConfirm && !await DialogUtils.confirm("Are you sure you want to delete this message? This can not be undone!")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,7 +1056,11 @@ export default {
|
|||||||
if(this.newMessageImage){
|
if(this.newMessageImage){
|
||||||
imageTotalSize = this.newMessageImage.size;
|
imageTotalSize = this.newMessageImage.size;
|
||||||
fields["image"] = {
|
fields["image"] = {
|
||||||
// Reticulum sends image type as "jpg" or "png" and not "image/jpg" or "image/png"
|
// Reticulum sends image type as "jpg", "png", "webp" etc and not "image/jpg" or "image/png"
|
||||||
|
// From memory, Sideband would not display images if the image type has the "image/" prefix
|
||||||
|
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/docs/example_plugins/view.py#L78
|
||||||
|
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/main.py#L1900
|
||||||
|
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/ui/messages.py#L783
|
||||||
"image_type": this.newMessageImage.type.replace("image/", ""),
|
"image_type": this.newMessageImage.type.replace("image/", ""),
|
||||||
"image_bytes": Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()),
|
"image_bytes": Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()),
|
||||||
};
|
};
|
||||||
@@ -1078,7 +1082,7 @@ export default {
|
|||||||
|
|
||||||
// ask user if they still want to send message if it may be rejected by sender
|
// ask user if they still want to send message if it may be rejected by sender
|
||||||
if(totalMessageSize > 1000 * 900){ // actual limit in LXMF Router is 1mb
|
if(totalMessageSize > 1000 * 900){ // actual limit in LXMF Router is 1mb
|
||||||
if(!confirm(`Your message exceeds 900KB (It's ${this.formatBytes(totalMessageSize)}). It may be rejected by the recipient unless they have increased their delivery limit. Do you want to try sending anyway?`)){
|
if(!await DialogUtils.confirm(`Your message exceeds 900KB (It's ${this.formatBytes(totalMessageSize)}). It may be rejected by the recipient unless they have increased their delivery limit. Do you want to try sending anyway?`)){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1209,10 +1213,10 @@ export default {
|
|||||||
clearFileInput: function() {
|
clearFileInput: function() {
|
||||||
this.$refs["file-input"].value = null;
|
this.$refs["file-input"].value = null;
|
||||||
},
|
},
|
||||||
removeImageAttachment: function() {
|
async removeImageAttachment() {
|
||||||
|
|
||||||
// ask user to confirm removing image attachment
|
// ask user to confirm removing image attachment
|
||||||
if(!confirm("Are you sure you want to remove this image attachment?")){
|
if(!await DialogUtils.confirm("Are you sure you want to remove this image attachment?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1244,7 +1248,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ask user to confirm recording new audio attachment, if an existing audio attachment exists
|
// ask user to confirm recording new audio attachment, if an existing audio attachment exists
|
||||||
if(this.newMessageAudio && !confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
|
if(this.newMessageAudio && !await DialogUtils.confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1386,10 +1390,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
removeAudioAttachment: function() {
|
async removeAudioAttachment() {
|
||||||
|
|
||||||
// ask user to confirm removing audio attachment
|
// ask user to confirm removing audio attachment
|
||||||
if(!confirm("Are you sure you want to remove this audio attachment?")){
|
if(!await DialogUtils.confirm("Are you sure you want to remove this audio attachment?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,61 @@
|
|||||||
<!-- nomadnetwork sidebar -->
|
<!-- nomadnetwork sidebar -->
|
||||||
<NomadNetworkSidebar
|
<NomadNetworkSidebar
|
||||||
:nodes="nodes"
|
:nodes="nodes"
|
||||||
|
:favourites="favourites"
|
||||||
:selected-destination-hash="selectedNode?.destination_hash"
|
:selected-destination-hash="selectedNode?.destination_hash"
|
||||||
@node-click="onNodeClick"/>
|
@node-click="onNodeClick"
|
||||||
|
@rename-favourite="onRenameFavourite"
|
||||||
|
@remove-favourite="onRemoveFavourite"/>
|
||||||
|
|
||||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||||
<!-- node -->
|
<!-- node -->
|
||||||
<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">
|
<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 -->
|
<!-- header -->
|
||||||
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
|
<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 -->
|
<!-- node info -->
|
||||||
<div class="my-auto dark:text-gray-100">
|
<div class="my-auto dark:text-gray-100">
|
||||||
<span class="font-semibold">{{ selectedNode.display_name }}</span>
|
<span class="font-semibold">{{ selectedNode.display_name }}</span>
|
||||||
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
|
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- close button -->
|
<!-- identify button -->
|
||||||
<div class="my-auto ml-auto mr-2">
|
<div class="my-auto ml-auto mr-2">
|
||||||
|
<div @click="identify(selectedNode.destination_hash)" 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="M7.864 4.243A7.5 7.5 0 0 1 19.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 0 0 4.5 10.5a7.464 7.464 0 0 1-1.15 3.993m1.989 3.559A11.209 11.209 0 0 0 8.25 10.5a3.75 3.75 0 1 1 7.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 0 1-3.6 9.75m6.633-4.596a18.666 18.666 0 0 1-2.485 5.33" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- close button -->
|
||||||
|
<div class="my-auto mr-2">
|
||||||
<div @click="onCloseNodeViewer" class="cursor-pointer">
|
<div @click="onCloseNodeViewer" 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 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>
|
<div>
|
||||||
@@ -152,10 +191,14 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
||||||
|
reloadInterval: null,
|
||||||
|
|
||||||
nodes: {},
|
nodes: {},
|
||||||
selectedNode: null,
|
selectedNode: null,
|
||||||
selectedNodePath: null,
|
selectedNodePath: null,
|
||||||
|
|
||||||
|
favourites: [],
|
||||||
|
|
||||||
isLoadingNodePage: false,
|
isLoadingNodePage: false,
|
||||||
isShowingNodePageSource: false,
|
isShowingNodePageSource: false,
|
||||||
defaultNodePagePath: "/page/index.mu",
|
defaultNodePagePath: "/page/index.mu",
|
||||||
@@ -178,6 +221,8 @@ export default {
|
|||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
|
|
||||||
|
clearInterval(this.reloadInterval);
|
||||||
|
|
||||||
// stop listening for websocket messages
|
// stop listening for websocket messages
|
||||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||||
|
|
||||||
@@ -202,8 +247,14 @@ export default {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.getFavourites();
|
||||||
this.getNomadnetworkNodeAnnounces();
|
this.getNomadnetworkNodeAnnounces();
|
||||||
|
|
||||||
|
// update info every few seconds
|
||||||
|
this.reloadInterval = setInterval(() => {
|
||||||
|
this.getFavourites();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onElementClick(event) {
|
onElementClick(event) {
|
||||||
@@ -309,6 +360,54 @@ export default {
|
|||||||
onDestinationPathClick: function(path) {
|
onDestinationPathClick: function(path) {
|
||||||
DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`);
|
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() {
|
async getNomadnetworkNodeAnnounces() {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -316,6 +415,7 @@ export default {
|
|||||||
const response = await window.axios.get(`/api/v1/announces`, {
|
const response = await window.axios.get(`/api/v1/announces`, {
|
||||||
params: {
|
params: {
|
||||||
aspect: "nomadnetwork.node",
|
aspect: "nomadnetwork.node",
|
||||||
|
limit: 500, // limit ui to showing 500 latest announces
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -737,6 +837,40 @@ export default {
|
|||||||
// load default node page
|
// load default node page
|
||||||
this.loadNodePage(node.destination_hash, this.defaultNodePagePath);
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
async onRemoveFavourite(favourite) {
|
||||||
|
|
||||||
|
// ask user to confirm
|
||||||
|
if(!await DialogUtils.confirm("Are you sure you want to remove this favourite?")){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeFavourite(favourite);
|
||||||
|
|
||||||
},
|
},
|
||||||
onCloseNodeViewer: function() {
|
onCloseNodeViewer: function() {
|
||||||
|
|
||||||
@@ -773,6 +907,24 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
|
async identify(destinationHash) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// ask user to confirm
|
||||||
|
if(!await DialogUtils.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) {
|
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,99 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col w-80 min-w-80">
|
<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 class="ml-auto my-auto">
|
||||||
|
<DropDownMenu>
|
||||||
|
<template v-slot:button>
|
||||||
|
<IconButton class="bg-transparent dark:bg-transparent">
|
||||||
|
<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="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
|
||||||
|
</svg>
|
||||||
|
</IconButton>
|
||||||
|
</template>
|
||||||
|
<template v-slot:items>
|
||||||
|
|
||||||
|
<!-- rename button -->
|
||||||
|
<DropDownMenuItem @click="onRenameFavourite(favourite)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||||
|
<path fill-rule="evenodd" d="M5.25 2.25a3 3 0 0 0-3 3v4.318a3 3 0 0 0 .879 2.121l9.58 9.581c.92.92 2.39 1.186 3.548.428a18.849 18.849 0 0 0 5.441-5.44c.758-1.16.492-2.629-.428-3.548l-9.58-9.581a3 3 0 0 0-2.122-.879H5.25ZM6.375 7.5a1.125 1.125 0 1 0 0-2.25 1.125 1.125 0 0 0 0 2.25Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>Rename Favourite</span>
|
||||||
|
</DropDownMenuItem>
|
||||||
|
|
||||||
|
<!-- remove favourite button -->
|
||||||
|
<div>
|
||||||
|
<DropDownMenuItem @click="onRemoveFavourite(favourite)">
|
||||||
|
<svg class="size-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-red-500">Remove Favourite</span>
|
||||||
|
</DropDownMenuItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</DropDownMenu>
|
||||||
|
</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 -->
|
<!-- search -->
|
||||||
<div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800">
|
<div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800">
|
||||||
<input
|
<input
|
||||||
@@ -58,6 +151,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -65,16 +159,22 @@
|
|||||||
|
|
||||||
import Utils from "../../js/Utils";
|
import Utils from "../../js/Utils";
|
||||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
|
import DropDownMenu from "../DropDownMenu.vue";
|
||||||
|
import IconButton from "../IconButton.vue";
|
||||||
|
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'NomadNetworkSidebar',
|
name: 'NomadNetworkSidebar',
|
||||||
components: {MaterialDesignIcon},
|
components: {DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon},
|
||||||
props: {
|
props: {
|
||||||
nodes: Object,
|
nodes: Object,
|
||||||
|
favourites: Array,
|
||||||
selectedDestinationHash: String,
|
selectedDestinationHash: String,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
tab: "favourites",
|
||||||
|
favouritesSearchTerm: "",
|
||||||
nodesSearchTerm: "",
|
nodesSearchTerm: "",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -82,9 +182,21 @@ export default {
|
|||||||
onNodeClick(node) {
|
onNodeClick(node) {
|
||||||
this.$emit("node-click", node);
|
this.$emit("node-click", node);
|
||||||
},
|
},
|
||||||
|
onFavouriteClick(favourite) {
|
||||||
|
this.onNodeClick(favourite);
|
||||||
|
},
|
||||||
|
onRenameFavourite(favourite) {
|
||||||
|
this.$emit("rename-favourite", favourite);
|
||||||
|
},
|
||||||
|
onRemoveFavourite(favourite) {
|
||||||
|
this.$emit("remove-favourite", favourite);
|
||||||
|
},
|
||||||
formatTimeAgo: function(datetimeString) {
|
formatTimeAgo: function(datetimeString) {
|
||||||
return Utils.formatTimeAgo(datetimeString);
|
return Utils.formatTimeAgo(datetimeString);
|
||||||
},
|
},
|
||||||
|
formatDestinationHash: function(destinationHash) {
|
||||||
|
return Utils.formatDestinationHash(destinationHash);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
nodesCount() {
|
nodesCount() {
|
||||||
@@ -107,6 +219,15 @@ export default {
|
|||||||
return matchesDisplayName || matchesDestinationHash;
|
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>
|
</script>
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// confirm user wants to update their icon
|
// confirm user wants to update their icon
|
||||||
if(!confirm("Are you sure you want to set this as your profile icon?")){
|
if(!await DialogUtils.confirm("Are you sure you want to set this as your profile icon?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ export default {
|
|||||||
async removeProfileIcon() {
|
async removeProfileIcon() {
|
||||||
|
|
||||||
// confirm user wants to remove their icon
|
// confirm user wants to remove their icon
|
||||||
if(!confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){
|
if(!await DialogUtils.confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ class DialogUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static confirm(message) {
|
||||||
|
if(window.electron){
|
||||||
|
// running inside electron, use ipc confirm
|
||||||
|
return window.electron.confirm(message);
|
||||||
|
} else {
|
||||||
|
// running inside normal browser, use browser alert
|
||||||
|
return window.confirm(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async prompt(message) {
|
static async prompt(message) {
|
||||||
if(window.electron){
|
if(window.electron){
|
||||||
// running inside electron, use ipc prompt
|
// running inside electron, use ipc prompt
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import moment from "moment";
|
|||||||
|
|
||||||
class Utils {
|
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) {
|
static formatBytes(bytes) {
|
||||||
|
|
||||||
if(bytes === 0){
|
if(bytes === 0){
|
||||||
|
|||||||
Reference in New Issue
Block a user