Compare commits
43 Commits
v1.23.1
...
actions-fu
| Author | SHA1 | Date | |
|---|---|---|---|
| 69d8bab9e4 | |||
| aa774f3511 | |||
| e0e2bbf091 | |||
| e9d45f257e | |||
| 00e0461a16 | |||
| c56b982df5 | |||
|
|
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 |
51
.github/workflows/build.yml
vendored
51
.github/workflows/build.yml
vendored
@@ -12,15 +12,15 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v1
|
||||
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||
|
||||
- name: Install NodeJS
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: ncipollo/release-action@v1
|
||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||
with:
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
@@ -50,15 +50,15 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v1
|
||||
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||
|
||||
- name: Install NodeJS
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: ncipollo/release-action@v1
|
||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||
with:
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
@@ -88,15 +88,15 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v1
|
||||
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||
|
||||
- name: Install NodeJS
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: ncipollo/release-action@v1
|
||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||
with:
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
@@ -127,31 +127,34 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Set lowercase repository owner
|
||||
run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/liamcottle/reticulum-meshchat:latest
|
||||
ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Reticulum MeshChat
|
||||
org.opencontainers.image.description=Docker image for Reticulum MeshChat
|
||||
org.opencontainers.image.url=https://github.com/liamcottle/reticulum-meshchat/pkgs/container/reticulum-meshchat/
|
||||
tags: >-
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:latest,
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:${{ github.ref_name }}
|
||||
labels: >-
|
||||
org.opencontainers.image.title=Reticulum MeshChat,
|
||||
org.opencontainers.image.description=Docker image for Reticulum MeshChat,
|
||||
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchat/
|
||||
|
||||
27
.github/workflows/manual-docker-build.yml
vendored
27
.github/workflows/manual-docker-build.yml
vendored
@@ -11,32 +11,35 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Set lowercase repository owner
|
||||
run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/liamcottle/reticulum-meshchat:latest
|
||||
ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Reticulum MeshChat
|
||||
org.opencontainers.image.description=Docker image for Reticulum MeshChat
|
||||
org.opencontainers.image.url=https://github.com/liamcottle/reticulum-meshchat/pkgs/container/reticulum-meshchat/
|
||||
tags: >-
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:latest,
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:${{ github.ref_name }}
|
||||
labels: >-
|
||||
org.opencontainers.image.title=Reticulum MeshChat,
|
||||
org.opencontainers.image.description=Docker image for Reticulum MeshChat,
|
||||
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchat/
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -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({
|
||||
@@ -99,7 +120,8 @@ function getDefaultReticulumConfigDir() {
|
||||
app.whenReady().then(async () => {
|
||||
|
||||
// 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");
|
||||
|
||||
if(!shouldLaunchHeadless){
|
||||
|
||||
@@ -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);
|
||||
|
||||
218
meshchat.py
218
meshchat.py
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -77,6 +79,7 @@ class ReticulumMeshChat:
|
||||
database.Config,
|
||||
database.Announce,
|
||||
database.CustomDestinationDisplayName,
|
||||
database.FavouriteDestination,
|
||||
database.LxmfMessage,
|
||||
database.LxmfConversationReadState,
|
||||
database.LxmfUserIcon,
|
||||
@@ -942,6 +945,7 @@ class ReticulumMeshChat:
|
||||
"version": self.get_app_version(),
|
||||
"lxmf_version": LXMF.__version__,
|
||||
"rns_version": RNS.__version__,
|
||||
"python_version": platform.python_version(),
|
||||
"storage_path": self.storage_path,
|
||||
"database_path": self.database_path,
|
||||
"database_file_size": os.path.getsize(self.database_path),
|
||||
@@ -1232,6 +1236,98 @@ 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!",
|
||||
})
|
||||
|
||||
# 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
|
||||
@routes.get("/api/v1/lxmf/propagation-node/status")
|
||||
async def index(request):
|
||||
@@ -1757,6 +1853,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):
|
||||
@@ -1920,6 +2040,9 @@ class ReticulumMeshChat:
|
||||
# called when web app has started
|
||||
async def on_startup(app):
|
||||
|
||||
# remember main event loop
|
||||
AsyncUtils.set_main_loop(asyncio.get_event_loop())
|
||||
|
||||
# auto launch web browser
|
||||
if launch_browser:
|
||||
try:
|
||||
@@ -2143,11 +2266,9 @@ class ReticulumMeshChat:
|
||||
},
|
||||
})))
|
||||
|
||||
# todo: handle file download progress
|
||||
|
||||
# download the file
|
||||
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
|
||||
elif _type == "nomadnet.page.download":
|
||||
@@ -2217,11 +2338,9 @@ class ReticulumMeshChat:
|
||||
},
|
||||
})))
|
||||
|
||||
# todo: handle page download progress
|
||||
|
||||
# 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)
|
||||
await downloader.download()
|
||||
AsyncUtils.run_async(downloader.download())
|
||||
|
||||
# unhandled type
|
||||
else:
|
||||
@@ -2507,6 +2626,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):
|
||||
|
||||
@@ -2717,6 +2847,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):
|
||||
|
||||
@@ -3271,7 +3417,7 @@ class Config:
|
||||
nomadnet_cached_links = {}
|
||||
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.aspects = "node"
|
||||
self.destination_hash = destination_hash
|
||||
@@ -3353,8 +3499,8 @@ class NomadnetDownloader:
|
||||
)
|
||||
|
||||
# handle successful download
|
||||
def on_response(self, request_receipt):
|
||||
self.on_download_success(request_receipt.response)
|
||||
def on_response(self, request_receipt: RNS.RequestReceipt):
|
||||
self.on_download_success(request_receipt)
|
||||
|
||||
# handle failure
|
||||
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)
|
||||
|
||||
# page download was successful, decode the response and send to provided callback
|
||||
def on_download_success(self, response_bytes):
|
||||
micron_markup_response = response_bytes.decode("utf-8")
|
||||
def on_download_success(self, request_receipt: RNS.RequestReceipt):
|
||||
micron_markup_response = request_receipt.response.decode("utf-8")
|
||||
self.on_page_download_success(micron_markup_response)
|
||||
|
||||
# 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)
|
||||
|
||||
# file download was successful, decode the response and send to provided callback
|
||||
def on_download_success(self, response):
|
||||
file_name: str = response[0]
|
||||
file_data: bytes = response[1]
|
||||
self.on_file_download_success(file_name, file_data)
|
||||
def on_download_success(self, request_receipt: RNS.RequestReceipt):
|
||||
|
||||
# get response
|
||||
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
|
||||
def on_download_failure(self, failure_reason):
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "reticulum-meshchat",
|
||||
"version": "1.21.0",
|
||||
"version": "2.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "reticulum-meshchat",
|
||||
"version": "1.21.0",
|
||||
"version": "2.2.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.10.0",
|
||||
"click-outside-vue3": "^4.0.1",
|
||||
"compressorjs": "^1.2.1",
|
||||
"electron-prompt": "^1.7.0",
|
||||
"micron-parser": "^1.0.1",
|
||||
"micron-parser": "^1.0.2",
|
||||
"mitt": "^3.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"postcss": "^8.4.49",
|
||||
@@ -1915,9 +1915,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.7.9",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
|
||||
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
@@ -4184,9 +4184,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micron-parser": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micron-parser/-/micron-parser-1.0.1.tgz",
|
||||
"integrity": "sha512-fh3BWIoowiYtSnXg7O8cYnF+qZhGmxTU0S1fwVGtSXb6j04MSfe+8R0KzOgIn2eAij682QGqoeaz5mM2+kCl6Q==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micron-parser/-/micron-parser-1.0.2.tgz",
|
||||
"integrity": "sha512-lYrEolylOUXeSISYrPRW/ZZAH1dpZRyTJ0VzQIA4cWJy0yNCXUUs+ujuAwV2OYlAPH8tCE1Z22+zg04Ilp/JWg==",
|
||||
"dependencies": {
|
||||
"dompurify": "*"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reticulum-meshchat",
|
||||
"version": "1.21.0",
|
||||
"version": "2.2.1",
|
||||
"description": "",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
@@ -98,11 +98,11 @@
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.10.0",
|
||||
"click-outside-vue3": "^4.0.1",
|
||||
"compressorjs": "^1.2.1",
|
||||
"electron-prompt": "^1.7.0",
|
||||
"micron-parser": "^1.0.1",
|
||||
"micron-parser": "^1.0.2",
|
||||
"mitt": "^3.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"postcss": "^8.4.49",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
aiohttp>=3.9.5
|
||||
aiohttp>=3.12.14
|
||||
cx_freeze>=7.0.0
|
||||
lxmf>=0.6.3
|
||||
peewee>=3.17.3
|
||||
rns>=0.9.3
|
||||
lxmf>=0.8.0
|
||||
peewee>=3.18.1
|
||||
rns>=1.0.0
|
||||
websockets>=14.2
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import asyncio
|
||||
from typing import Coroutine
|
||||
|
||||
|
||||
class AsyncUtils:
|
||||
|
||||
# this method allows running the provided async coroutine from within a sync function
|
||||
# it will run the async function on the existing event loop if available, otherwise it will start a new event loop
|
||||
# remember main loop
|
||||
main_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
@staticmethod
|
||||
def run_async(coroutine):
|
||||
def set_main_loop(loop: asyncio.AbstractEventLoop):
|
||||
AsyncUtils.main_loop = loop
|
||||
|
||||
# attempt to get existing event loop
|
||||
existing_event_loop = None
|
||||
try:
|
||||
existing_event_loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# 'RuntimeError: no running event loop'
|
||||
pass
|
||||
# this method allows running the provided async coroutine from within a sync function
|
||||
# it will run the async function on the main event loop if possible, otherwise it logs a warning
|
||||
@staticmethod
|
||||
def run_async(coroutine: Coroutine):
|
||||
|
||||
# if there is an existing event loop running, submit the coroutine to that loop
|
||||
if existing_event_loop and existing_event_loop.is_running():
|
||||
existing_event_loop.create_task(coroutine)
|
||||
# run provided coroutine on main event loop, ensuring thread safety
|
||||
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
|
||||
return
|
||||
|
||||
# otherwise start a new event loop to run the coroutine
|
||||
asyncio.run(coroutine)
|
||||
# main event loop not running...
|
||||
print("WARNING: Main event loop not available. Could not schedule task.")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="mr-auto">
|
||||
<div>Versions</div>
|
||||
<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 class="hidden sm:block mx-2 my-auto">
|
||||
|
||||
@@ -259,6 +259,7 @@
|
||||
|
||||
<script>
|
||||
import protobuf from "protobufjs";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
export default {
|
||||
name: 'CallPage',
|
||||
data() {
|
||||
@@ -488,7 +489,7 @@ export default {
|
||||
async hangupCall(callHash) {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -681,7 +682,7 @@ export default {
|
||||
async deleteCall(callHash) {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -701,7 +702,7 @@ export default {
|
||||
async clearCallHistory() {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -690,7 +690,7 @@
|
||||
<input
|
||||
type="number"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -699,7 +699,7 @@
|
||||
<input
|
||||
type="number"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -191,7 +191,7 @@ export default {
|
||||
async deleteInterface(interfaceName) {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export default {
|
||||
async onDeleteMessageHistory() {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -997,7 +997,7 @@ export default {
|
||||
try {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -1056,7 +1056,11 @@ export default {
|
||||
if(this.newMessageImage){
|
||||
imageTotalSize = this.newMessageImage.size;
|
||||
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_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
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1209,10 +1213,10 @@ export default {
|
||||
clearFileInput: function() {
|
||||
this.$refs["file-input"].value = null;
|
||||
},
|
||||
removeImageAttachment: function() {
|
||||
async removeImageAttachment() {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -1244,7 +1248,7 @@ export default {
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -1386,10 +1390,10 @@ export default {
|
||||
}
|
||||
|
||||
},
|
||||
removeAudioAttachment: function() {
|
||||
async removeAudioAttachment() {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,22 +3,61 @@
|
||||
<!-- nomadnetwork sidebar -->
|
||||
<NomadNetworkSidebar
|
||||
:nodes="nodes"
|
||||
:favourites="favourites"
|
||||
: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">
|
||||
<!-- 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">
|
||||
<!-- 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>
|
||||
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
|
||||
</div>
|
||||
|
||||
<!-- close button -->
|
||||
<!-- identify button -->
|
||||
<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 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>
|
||||
@@ -152,10 +191,14 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
|
||||
reloadInterval: null,
|
||||
|
||||
nodes: {},
|
||||
selectedNode: null,
|
||||
selectedNodePath: null,
|
||||
|
||||
favourites: [],
|
||||
|
||||
isLoadingNodePage: false,
|
||||
isShowingNodePageSource: false,
|
||||
defaultNodePagePath: "/page/index.mu",
|
||||
@@ -178,6 +221,8 @@ export default {
|
||||
},
|
||||
beforeUnmount() {
|
||||
|
||||
clearInterval(this.reloadInterval);
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
|
||||
@@ -202,8 +247,14 @@ export default {
|
||||
})();
|
||||
}
|
||||
|
||||
this.getFavourites();
|
||||
this.getNomadnetworkNodeAnnounces();
|
||||
|
||||
// update info every few seconds
|
||||
this.reloadInterval = setInterval(() => {
|
||||
this.getFavourites();
|
||||
}, 5000);
|
||||
|
||||
},
|
||||
methods: {
|
||||
onElementClick(event) {
|
||||
@@ -309,6 +360,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 {
|
||||
|
||||
@@ -316,6 +415,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
|
||||
},
|
||||
});
|
||||
|
||||
@@ -737,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");
|
||||
}
|
||||
|
||||
},
|
||||
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() {
|
||||
|
||||
@@ -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) {
|
||||
try {
|
||||
|
||||
|
||||
@@ -1,6 +1,99 @@
|
||||
<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 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 -->
|
||||
<div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800">
|
||||
<input
|
||||
@@ -58,6 +151,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -65,16 +159,22 @@
|
||||
|
||||
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,
|
||||
selectedDestinationHash: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tab: "favourites",
|
||||
favouritesSearchTerm: "",
|
||||
nodesSearchTerm: "",
|
||||
};
|
||||
},
|
||||
@@ -82,9 +182,21 @@ export default {
|
||||
onNodeClick(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) {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
},
|
||||
formatDestinationHash: function(destinationHash) {
|
||||
return Utils.formatDestinationHash(destinationHash);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
nodesCount() {
|
||||
@@ -107,6 +219,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>
|
||||
|
||||
@@ -145,7 +145,7 @@ export default {
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ export default {
|
||||
async removeProfileIcon() {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
if(window.electron){
|
||||
// running inside electron, use ipc prompt
|
||||
|
||||
@@ -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