Compare commits

..

15 Commits

Author SHA1 Message Date
Ivan
936c298e15 update 2025-05-11 14:21:01 -05:00
Ivan
f5dc06ab88 update 2025-05-11 14:20:10 -05:00
Ivan
24e2ac9c65 update 2025-05-09 18:45:33 -05:00
Ivan
349f50b87f update 2025-05-09 18:18:36 -05:00
Ivan
5f8c476f18 fix 2025-04-22 17:58:46 -05:00
Ivan
dbf5361fe4 fix 2025-04-22 17:55:30 -05:00
Ivan
54a92ad5d5 update 2025-04-22 17:54:45 -05:00
Ivan
d59e91ced3 update 2025-04-22 17:53:27 -05:00
Ivan
31dacb357f update 2025-04-22 17:51:42 -05:00
Ivan
daeda58b80 add bearer 2025-04-22 17:47:22 -05:00
Ivan
195daf343d update 2025-04-22 17:47:03 -05:00
Ivan
c41e022e4f use my image 2025-04-22 17:46:54 -05:00
Ivan
15c4355a58 update package-lock 2025-04-22 17:46:39 -05:00
Ivan
a23f64067a update 2025-04-22 17:34:08 -05:00
Ivan
cf72ac1ec8 update 2025-04-22 17:20:07 -05:00
29 changed files with 1437 additions and 1222 deletions

View File

@@ -1,55 +0,0 @@
# 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

36
.github/workflows/bearer.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Security Scan
on:
push:
branches:
- master
pull_request:
branches:
- master
schedule:
- cron: '0 0 * * 0' # Run weekly on Sunday
permissions:
contents: read
security-events: write
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Bearer Security Scan
uses: bearer/bearer-action@v2
with:
scanner: sast
format: sarif
output: bearer.sarif
severity: critical,high
path: .
exit-code: 0
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: bearer.sarif

View File

@@ -149,9 +149,9 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
ghcr.io/liamcottle/reticulum-meshchat:latest ghcr.io/sudo-ivan/reticulum-meshchat:latest
ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }} ghcr.io/sudo-ivan/reticulum-meshchat:${{ github.ref_name }}
labels: | labels: |
org.opencontainers.image.title=Reticulum MeshChat org.opencontainers.image.title=Reticulum MeshChat
org.opencontainers.image.description=Docker image for 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/ org.opencontainers.image.url=https://github.com/Sudo-Ivan/reticulum-meshchat/pkgs/container/reticulum-meshchat/

View File

@@ -33,10 +33,10 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
ghcr.io/liamcottle/reticulum-meshchat:latest ghcr.io/sudo-ivan/reticulum-meshchat:latest
ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }} ghcr.io/sudo-ivan/reticulum-meshchat:${{ github.ref_name }}
labels: | labels: |
org.opencontainers.image.title=Reticulum MeshChat org.opencontainers.image.title=Reticulum MeshChat
org.opencontainers.image.description=Docker image for 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/ org.opencontainers.image.url=https://github.com/Sudo-Ivan/reticulum-meshchat/pkgs/container/reticulum-meshchat/

4
.gitignore vendored
View File

@@ -9,3 +9,7 @@ node_modules
# local storage # local storage
storage/ storage/
__pycache__/
config/

View File

@@ -1,41 +1,51 @@
# 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:${NODE_VERSION}-alpine@${NODE_ALPINE_SHA256} AS build-frontend FROM node:20-alpine AS build-frontend
WORKDIR /src WORKDIR /src
# Copy required source files # Copy required source files
COPY *.json . COPY --chown=node:node *.json .
COPY *.js . COPY --chown=node:node *.js .
COPY src/frontend ./src/frontend COPY --chown=node:node src/frontend ./src/frontend
# Install NodeJS deps, exluding electron # Fix permissions and install NodeJS deps
USER root
RUN chown -R node:node /src
USER node
RUN npm install --omit=dev && \ RUN npm install --omit=dev && \
npm run build-frontend npm run build-frontend
# Main app build # Main app build
FROM python:${PYTHON_VERSION}-alpine@${PYTHON_ALPINE_SHA256} FROM python:3.13-alpine
WORKDIR /app WORKDIR /app
# Install Python deps # Install system dependencies
COPY ./requirements.txt . RUN apk add --no-cache \
RUN apk add --no-cache --virtual .build-deps gcc musl-dev && \ gcc \
pip install -r requirements.txt && \ musl-dev \
apk del .build-deps python3-dev \
libffi-dev \
openssl-dev
# Copy prebuilt frontend # Create config directories with proper permissions
COPY --from=build-frontend /src/public public RUN mkdir -p /config/.reticulum /config/.meshchat && \
chown -R 1000:1000 /config
# Install Python deps
COPY --chown=1000:1000 ./requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Create public directory and copy frontend
RUN mkdir -p /app/public
COPY --from=build-frontend --chown=1000:1000 /src/public/ /app/public/
# Copy other required source files # Copy other required source files
COPY *.py . COPY --chown=1000:1000 *.py .
COPY src/__init__.py ./src/__init__.py COPY --chown=1000:1000 src/__init__.py ./src/__init__.py
COPY src/backend ./src/backend COPY --chown=1000:1000 src/backend ./src/backend
COPY *.json . COPY --chown=1000:1000 *.json .
CMD ["python", "meshchat.py", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"] USER 1000
ENTRYPOINT ["python"]
CMD ["meshchat.py", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]

View File

@@ -1,3 +1,21 @@
# Ivans Fork Edition
## Containers
- Drop unnecassary permissions (compose)
- Rootless (user 1000:1000)
- Resource Limits (compose)
- Alpine Image Variants
- Updated Dependencies
- Dockerfile use python 3.13
## Security
- Bearer Security Scan Action
- [Socket](https://socket.dev/) Supply Chain Security/Analysis
---
<p align="center"> <p align="center">
<a href="https://github.com/liamcottle/reticulum-meshchat"><img src="./logo/logo-chat-bubble.png" width="150"></a> <a href="https://github.com/liamcottle/reticulum-meshchat"><img src="./logo/logo-chat-bubble.png" width="150"></a>
</p> </p>

View File

@@ -95,21 +95,6 @@ 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()

40
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,40 @@
services:
reticulum-meshchat:
container_name: reticulum-meshchat-dev
build:
context: .
dockerfile: Dockerfile
pull_policy: never
restart: unless-stopped
user: "1000:1000"
# Make the meshchat web interface accessible from the host on port 8000
ports:
- 0.0.0.0:8000:8000
volumes:
- meshchat-config:/config:rw
- .:/app:delegated
- /app/public
# Uncomment if you have a USB device connected, such as an RNode
# devices:
# - /dev/ttyUSB0:/dev/ttyUSB0
cap_drop:
- ALL
cap_add:
- NET_ADMIN
- NET_RAW
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
volumes:
meshchat-config:
driver: local
driver_opts:
type: none
o: bind
device: ${PWD}/config

View File

@@ -1,17 +1,31 @@
services: services:
reticulum-meshchat: reticulum-meshchat:
container_name: reticulum-meshchat container_name: reticulum-meshchat
image: ghcr.io/liamcottle/reticulum-meshchat:latest image: ghcr.io/sudo-ivan/reticulum-meshchat:latest
pull_policy: always pull_policy: always
restart: unless-stopped restart: unless-stopped
user: "1000:1000"
# Make the meshchat web interface accessible from the host on port 8000 # Make the meshchat web interface accessible from the host on port 8000
ports: ports:
- 0.0.0.0:8000:8000 - 0.0.0.0:8000:8000
volumes: volumes:
- meshchat-config:/config - meshchat-config:/config:rw
# Uncomment if you have a USB device connected, such as an RNode # Uncomment if you have a USB device connected, such as an RNode
# devices: # devices:
# - /dev/ttyUSB0:/dev/ttyUSB0 # - /dev/ttyUSB0:/dev/ttyUSB0
cap_drop:
- ALL
cap_add:
- NET_ADMIN
- NET_RAW
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
volumes: volumes:
meshchat-config: meshchat-config:

View File

@@ -22,27 +22,6 @@ 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({
@@ -120,8 +99,9 @@ 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 ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"]; const ignoredArguments = ["--no-sandbox"];
const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg)); 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){

View File

@@ -15,11 +15,6 @@ 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);

View File

@@ -1,10 +1,8 @@
#!/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
@@ -79,7 +77,6 @@ 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,
@@ -945,7 +942,6 @@ 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),
@@ -1236,98 +1232,6 @@ 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):
@@ -1853,30 +1757,6 @@ 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):
@@ -2040,9 +1920,6 @@ 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:
@@ -2266,9 +2143,11 @@ 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)
AsyncUtils.run_async(downloader.download()) await 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":
@@ -2338,9 +2217,11 @@ 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)
AsyncUtils.run_async(downloader.download()) await downloader.download()
# unhandled type # unhandled type
else: else:
@@ -2626,17 +2507,6 @@ 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):
@@ -2847,22 +2717,6 @@ 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):
@@ -3417,7 +3271,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[[RNS.RequestReceipt], 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[[bytes], 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
@@ -3499,8 +3353,8 @@ class NomadnetDownloader:
) )
# handle successful download # handle successful download
def on_response(self, request_receipt: RNS.RequestReceipt): def on_response(self, request_receipt):
self.on_download_success(request_receipt) self.on_download_success(request_receipt.response)
# handle failure # handle failure
def on_failed(self, request_receipt=None): def on_failed(self, request_receipt=None):
@@ -3519,8 +3373,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, request_receipt: RNS.RequestReceipt): def on_download_success(self, response_bytes):
micron_markup_response = request_receipt.response.decode("utf-8") micron_markup_response = response_bytes.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
@@ -3536,52 +3390,10 @@ 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, request_receipt: RNS.RequestReceipt): def on_download_success(self, response):
file_name: str = response[0]
# get response file_data: bytes = response[1]
response = request_receipt.response self.on_file_download_success(file_name, file_data)
# 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):

1757
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "reticulum-meshchat", "name": "reticulum-meshchat",
"version": "2.2.1", "version": "1.22.2",
"description": "", "description": "",
"main": "electron/main.js", "main": "electron/main.js",
"scripts": { "scripts": {
@@ -96,23 +96,23 @@
"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.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.10.0", "axios": "^1.9.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.2", "micron-parser": "^1.0.1",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"protobufjs": "^7.4.0", "protobufjs": "^7.5.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vis-data": "^7.1.9", "vis-data": "^7.1.9",
"vis-network": "^9.1.9", "vis-network": "^9.1.9",
"vite": "^6.0.5", "vite": "^6.3.5",
"vite-plugin-vuetify": "^2.0.4", "vite-plugin-vuetify": "^2.0.4",
"vue-router": "^4.5.0", "vue-router": "^4.5.1",
"vuetify": "^3.7.6" "vuetify": "^3.8.4"
} }
} }

View File

@@ -1,6 +1,6 @@
aiohttp>=3.12.14 aiohttp>=3.11.18
cx_freeze>=7.0.0 cx_freeze>=7.0.0
lxmf>=0.8.0 lxmf>=0.6.3
peewee>=3.18.1 peewee>=3.18.1
rns>=1.0.0 rns>=0.9.5
websockets>=14.2 websockets>=15.0.1

View File

@@ -1,25 +1,25 @@
import asyncio import asyncio
from typing import Coroutine
class AsyncUtils: class AsyncUtils:
# remember main loop
main_loop: asyncio.AbstractEventLoop | None = None
@staticmethod
def set_main_loop(loop: asyncio.AbstractEventLoop):
AsyncUtils.main_loop = loop
# this method allows running the provided async coroutine from within a sync function # 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 # it will run the async function on the existing event loop if available, otherwise it will start a new event loop
@staticmethod @staticmethod
def run_async(coroutine: Coroutine): def run_async(coroutine):
# run provided coroutine on main event loop, ensuring thread safety # attempt to get existing event loop
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running(): existing_event_loop = None
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop) try:
existing_event_loop = asyncio.get_running_loop()
except RuntimeError:
# 'RuntimeError: no running event loop'
pass
# 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)
return return
# main event loop not running... # otherwise start a new event loop to run the coroutine
print("WARNING: Main event loop not available. Could not schedule task.") asyncio.run(coroutine)

View File

@@ -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(await DialogUtils.confirm("Are you sure you want to stop syncing?")){ if(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(!await DialogUtils.confirm("Are you sure you want to hang up all incoming and outgoing calls?")){ if(!confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
return; return;
} }

View File

@@ -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 }} • Python v{{ appInfo.python_version }} MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }}
</div> </div>
</div> </div>
<div class="hidden sm:block mx-2 my-auto"> <div class="hidden sm:block mx-2 my-auto">

View File

@@ -259,7 +259,6 @@
<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() {
@@ -489,7 +488,7 @@ export default {
async hangupCall(callHash) { async hangupCall(callHash) {
// confirm user wants to hang up call // confirm user wants to hang up call
if(!await DialogUtils.confirm("Are you sure you want to hang up this call?")){ if(!confirm("Are you sure you want to hang up this call?")){
return; return;
} }
@@ -682,7 +681,7 @@ export default {
async deleteCall(callHash) { async deleteCall(callHash) {
// confirm user wants to delete call // confirm user wants to delete call
if(!await DialogUtils.confirm("Are you sure you want to delete this call?")){ if(!confirm("Are you sure you want to delete this call?")){
return; return;
} }
@@ -702,7 +701,7 @@ export default {
async clearCallHistory() { async clearCallHistory() {
// confirm user wants to clear call history // confirm user wants to clear call history
if(!await DialogUtils.confirm("Are you sure you want to clear your call history?")){ if(!confirm("Are you sure you want to clear your call history?")){
return; return;
} }

View File

@@ -690,7 +690,7 @@
<input <input
type="number" type="number"
v-model="newInterfaceAirtimeLimitShort" v-model="newInterfaceAirtimeLimitShort"
placeholder="Enter short airtime limit (% of a rolling 15 seconds window)" placeholder="Enter short airtime limit (seconds)"
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 (% of a rolling 60 minutes window)" placeholder="Enter long airtime limit (seconds)"
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>

View File

@@ -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(!await DialogUtils.confirm("Are you sure you want to delete this interface? This can not be undone!")){ if(!confirm("Are you sure you want to delete this interface? This can not be undone!")){
return; return;
} }

View File

@@ -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(!await DialogUtils.confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){ if(!confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
return; return;
} }

View File

@@ -997,7 +997,7 @@ export default {
try { try {
// ask user to confirm deleting message // ask user to confirm deleting message
if(shouldConfirm && !await DialogUtils.confirm("Are you sure you want to delete this message? This can not be undone!")){ if(shouldConfirm && !confirm("Are you sure you want to delete this message? This can not be undone!")){
return; return;
} }
@@ -1056,11 +1056,7 @@ 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", "png", "webp" etc and not "image/jpg" or "image/png" // Reticulum sends image type as "jpg" or "png" 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()),
}; };
@@ -1082,7 +1078,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(!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?`)){ 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?`)){
return; return;
} }
} }
@@ -1213,10 +1209,10 @@ export default {
clearFileInput: function() { clearFileInput: function() {
this.$refs["file-input"].value = null; this.$refs["file-input"].value = null;
}, },
async removeImageAttachment() { removeImageAttachment: function() {
// ask user to confirm removing image attachment // ask user to confirm removing image attachment
if(!await DialogUtils.confirm("Are you sure you want to remove this image attachment?")){ if(!confirm("Are you sure you want to remove this image attachment?")){
return; return;
} }
@@ -1248,7 +1244,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 && !await DialogUtils.confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){ if(this.newMessageAudio && !confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
return; return;
} }
@@ -1390,10 +1386,10 @@ export default {
} }
}, },
async removeAudioAttachment() { removeAudioAttachment: function() {
// ask user to confirm removing audio attachment // ask user to confirm removing audio attachment
if(!await DialogUtils.confirm("Are you sure you want to remove this audio attachment?")){ if(!confirm("Are you sure you want to remove this audio attachment?")){
return; return;
} }

View File

@@ -3,61 +3,22 @@
<!-- 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>
<!-- 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 --> <!-- close button -->
<div class="my-auto mr-2"> <div class="my-auto ml-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>
@@ -191,14 +152,10 @@ 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",
@@ -221,8 +178,6 @@ 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);
@@ -247,14 +202,8 @@ 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) {
@@ -360,54 +309,6 @@ 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 {
@@ -415,7 +316,6 @@ 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
}, },
}); });
@@ -837,40 +737,6 @@ 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() {
@@ -907,24 +773,6 @@ 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 {

View File

@@ -1,99 +1,6 @@
<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
@@ -151,7 +58,6 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
@@ -159,22 +65,16 @@
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: {DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon}, components: {MaterialDesignIcon},
props: { props: {
nodes: Object, nodes: Object,
favourites: Array,
selectedDestinationHash: String, selectedDestinationHash: String,
}, },
data() { data() {
return { return {
tab: "favourites",
favouritesSearchTerm: "",
nodesSearchTerm: "", nodesSearchTerm: "",
}; };
}, },
@@ -182,21 +82,9 @@ 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() {
@@ -219,15 +107,6 @@ 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>

View File

@@ -145,7 +145,7 @@ export default {
} }
// confirm user wants to update their icon // confirm user wants to update their icon
if(!await DialogUtils.confirm("Are you sure you want to set this as your profile icon?")){ if(!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(!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.")){ 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.")){
return; return;
} }

View File

@@ -10,16 +10,6 @@ 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

View File

@@ -2,13 +2,6 @@ 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){