Compare commits

..

21 Commits

Author SHA1 Message Date
Ivan
6b3713e58f flatpak 2025-05-11 14:56:33 -05:00
Ivan
09bd78e194 node 22 and python 3.13 2025-05-11 14:43:47 -05:00
Ivan
3288fea934 update 2025-05-11 14:31:50 -05:00
Ivan
47f544e2ee ruff format and fixes.
Improve page download.
2025-05-11 14:28:57 -05:00
Ivan
be3d8f1e80 update 2025-05-11 14:22:39 -05:00
Ivan
01b1251589 dark mode by default 2025-05-11 14:22:35 -05:00
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
42 changed files with 2727 additions and 2714 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

View File

@@ -1,20 +0,0 @@
name: Bearer PR Check
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
security-events: write
jobs:
rule_check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Bearer
uses: bearer/bearer-action@828eeb928ce2f4a7ca5ed57fb8b59508cb8c79bc # v2
with:
diff: true

View File

@@ -1,29 +1,36 @@
name: Bearer Master name: Security Scan
on: on:
push: push:
branches: branches:
- master - master
pull_request:
branches:
- master
schedule:
- cron: '0 0 * * 0' # Run weekly on Sunday
permissions: permissions:
contents: read
security-events: write security-events: write
jobs: jobs:
rule_check: security-scan:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@v4
- name: Bearer - name: Bearer Security Scan
uses: bearer/bearer-action@828eeb928ce2f4a7ca5ed57fb8b59508cb8c79bc # v2 uses: bearer/bearer-action@v2
with: with:
scanner: sast
format: sarif format: sarif
output: results.sarif output: bearer.sarif
severity: critical,high
- name: Upload SARIF file path: .
if: always() exit-code: 0
uses: github/codeql-action/upload-sarif@2827891b2e5e0510dceab8c3619f4fe255451277 # v4
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@v2
with: with:
sarif_file: results.sarif sarif_file: bearer.sarif
category: bearer-security-scan

View File

@@ -12,15 +12,15 @@ jobs:
contents: write contents: write
steps: steps:
- name: Clone Repo - name: Clone Repo
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1 uses: actions/checkout@v1
- name: Install NodeJS - name: Install NodeJS
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1 uses: actions/setup-node@v1
with: with:
node-version: 22 node-version: 22
- name: Install Python - name: Install Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 uses: actions/setup-python@v5
with: with:
python-version: "3.13" python-version: "3.13"
@@ -35,7 +35,7 @@ jobs:
- name: Create Release - name: Create Release
id: create_release id: create_release
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1 uses: ncipollo/release-action@v1
with: with:
draft: true draft: true
allowUpdates: true allowUpdates: true
@@ -44,43 +44,43 @@ jobs:
omitNameDuringUpdate: true omitNameDuringUpdate: true
artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe" artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
build_mac: # build_mac:
runs-on: macos-13 # runs-on: macos-13
permissions: # permissions:
contents: write # contents: write
steps: # steps:
- name: Clone Repo # - name: Clone Repo
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1 # uses: actions/checkout@v1
- name: Install NodeJS # - name: Install NodeJS
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1 # uses: actions/setup-node@v1
with: # with:
node-version: 18 # node-version: 20
- name: Install Python # - name: Install Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 # uses: actions/setup-python@v5
with: # with:
python-version: "3.11" # python-version: "3.13"
- name: Install Python Deps # - name: Install Python Deps
run: pip install -r requirements.txt # run: pip install -r requirements.txt
- name: Install NodeJS Deps # - name: Install NodeJS Deps
run: npm install # run: npm install
- name: Build Electron App # - name: Build Electron App
run: npm run dist # run: npm run dist
- name: Create Release # - name: Create Release
id: create_release # id: create_release
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1 # uses: ncipollo/release-action@v1
with: # with:
draft: true # draft: true
allowUpdates: true # allowUpdates: true
replacesArtifacts: true # replacesArtifacts: true
omitDraftDuringUpdate: true # omitDraftDuringUpdate: true
omitNameDuringUpdate: true # omitNameDuringUpdate: true
artifacts: "dist/*-mac.dmg" # artifacts: "dist/*-mac.dmg"
build_linux: build_linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -88,15 +88,15 @@ jobs:
contents: write contents: write
steps: steps:
- name: Clone Repo - name: Clone Repo
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1 uses: actions/checkout@v1
- name: Install NodeJS - name: Install NodeJS
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1 uses: actions/setup-node@v1
with: with:
node-version: 22 node-version: 22
- name: Install Python - name: Install Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 uses: actions/setup-python@v5
with: with:
python-version: "3.13" python-version: "3.13"
@@ -109,16 +109,22 @@ jobs:
- name: Build Electron App - name: Build Electron App
run: npm run dist run: npm run dist
- name: Upload Flatpak Artifact
uses: actions/upload-artifact@v3
with:
name: flatpak
path: dist/*.flatpak
- name: Create Release - name: Create Release
id: create_release id: create_release
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1 uses: ncipollo/release-action@v1
with: with:
draft: true draft: true
allowUpdates: true allowUpdates: true
replacesArtifacts: true replacesArtifacts: true
omitDraftDuringUpdate: true omitDraftDuringUpdate: true
omitNameDuringUpdate: true omitNameDuringUpdate: true
artifacts: "dist/*-linux.AppImage,dist/*-linux.deb" artifacts: "dist/*-linux.AppImage"
build_docker: build_docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -127,34 +133,31 @@ jobs:
contents: read contents: read
steps: steps:
- name: Clone Repo - name: Clone Repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@v4
- name: Set lowercase repository owner
run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 uses: docker/setup-buildx-action@v3
- name: Log in to the GitHub Container registry - name: Log in to the GitHub Container registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker images - name: Build and push Docker images
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: >- tags: |
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:latest, ghcr.io/sudo-ivan/reticulum-meshchat:latest
ghcr.io/${{ env.REPO_OWNER_LC }}/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/${{ github.repository }}/pkgs/container/reticulum-meshchat/ org.opencontainers.image.url=https://github.com/Sudo-Ivan/reticulum-meshchat/pkgs/container/reticulum-meshchat/

View File

@@ -11,35 +11,32 @@ jobs:
contents: read contents: read
steps: steps:
- name: Clone Repo - name: Clone Repo
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@v4
- name: Set lowercase repository owner
run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 uses: docker/setup-buildx-action@v3
- name: Log in to the GitHub Container registry - name: Log in to the GitHub Container registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker images - name: Build and push Docker images
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: >- tags: |
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:latest, ghcr.io/sudo-ivan/reticulum-meshchat:latest
ghcr.io/${{ env.REPO_OWNER_LC }}/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/${{ github.repository }}/pkgs/container/reticulum-meshchat/ org.opencontainers.image.url=https://github.com/Sudo-Ivan/reticulum-meshchat/pkgs/container/reticulum-meshchat/

4
.gitignore vendored
View File

@@ -10,4 +10,6 @@ node_modules
# local storage # local storage
storage/ storage/
*.pyc __pycache__/
config/

View File

@@ -1,45 +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:22-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 \
gcc \ musl-dev \
musl-dev \ python3-dev \
linux-headers \ libffi-dev \
python3-dev && \ openssl-dev
pip install -r requirements.txt && \
apk del .build-deps
# 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,26 @@
# Ivans Custom Fork Edition
highly experimental and customized, only use if you live on the edge.
## Changes
- Drop unnecassary permissions (compose)
- Rootless (user 1000:1000)
- Resource Limits (compose)
- Alpine Image Variants.
- Updated Dependencies.
- Dark mode by default.
- Python 3.13 and Node 20.
- Ruff formatting and fixes.
- Flatpak support. (WIP)
## 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

@@ -4,40 +4,47 @@ from peewee import *
from playhouse.migrate import migrate as migrate_database, SqliteMigrator from playhouse.migrate import migrate as migrate_database, SqliteMigrator
latest_version = 5 # increment each time new database migrations are added latest_version = 5 # increment each time new database migrations are added
database = DatabaseProxy() # use a proxy object, as we will init real db client inside meshchat.py database = (
DatabaseProxy()
) # use a proxy object, as we will init real db client inside meshchat.py
migrator = SqliteMigrator(database) migrator = SqliteMigrator(database)
# migrates the database # migrates the database
def migrate(current_version): def migrate(current_version):
# migrate to version 2 # migrate to version 2
if current_version < 2: if current_version < 2:
migrate_database( migrate_database(
migrator.add_column("lxmf_messages", 'delivery_attempts', LxmfMessage.delivery_attempts), migrator.add_column(
migrator.add_column("lxmf_messages", 'next_delivery_attempt_at', LxmfMessage.next_delivery_attempt_at), "lxmf_messages", "delivery_attempts", LxmfMessage.delivery_attempts
),
migrator.add_column(
"lxmf_messages",
"next_delivery_attempt_at",
LxmfMessage.next_delivery_attempt_at,
),
) )
# migrate to version 3 # migrate to version 3
if current_version < 3: if current_version < 3:
migrate_database( migrate_database(
migrator.add_column("lxmf_messages", 'rssi', LxmfMessage.rssi), migrator.add_column("lxmf_messages", "rssi", LxmfMessage.rssi),
migrator.add_column("lxmf_messages", 'snr', LxmfMessage.snr), migrator.add_column("lxmf_messages", "snr", LxmfMessage.snr),
migrator.add_column("lxmf_messages", 'quality', LxmfMessage.quality), migrator.add_column("lxmf_messages", "quality", LxmfMessage.quality),
) )
# migrate to version 4 # migrate to version 4
if current_version < 4: if current_version < 4:
migrate_database( migrate_database(
migrator.add_column("lxmf_messages", 'method', LxmfMessage.method), migrator.add_column("lxmf_messages", "method", LxmfMessage.method),
) )
# migrate to version 5 # migrate to version 5
if current_version < 5: if current_version < 5:
migrate_database( migrate_database(
migrator.add_column("announces", 'rssi', Announce.rssi), migrator.add_column("announces", "rssi", Announce.rssi),
migrator.add_column("announces", 'snr', Announce.snr), migrator.add_column("announces", "snr", Announce.snr),
migrator.add_column("announces", 'quality', Announce.quality), migrator.add_column("announces", "quality", Announce.quality),
) )
return latest_version return latest_version
@@ -49,7 +56,6 @@ class BaseModel(Model):
class Config(BaseModel): class Config(BaseModel):
id = BigAutoField() id = BigAutoField()
key = CharField(unique=True) key = CharField(unique=True)
value = TextField() value = TextField()
@@ -62,12 +68,19 @@ class Config(BaseModel):
class Announce(BaseModel): class Announce(BaseModel):
id = BigAutoField() id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash that was announced destination_hash = CharField(
aspect = TextField(index=True) # aspect is not included in announce, but we want to filter saved announces by aspect unique=True
identity_hash = CharField(index=True) # identity hash that announced the destination ) # unique destination hash that was announced
identity_public_key = CharField() # base64 encoded public key, incase we want to recreate the identity manually aspect = TextField(
index=True
) # aspect is not included in announce, but we want to filter saved announces by aspect
identity_hash = CharField(
index=True
) # identity hash that announced the destination
identity_public_key = (
CharField()
) # base64 encoded public key, incase we want to recreate the identity manually
app_data = TextField(null=True) # base64 encoded app data bytes app_data = TextField(null=True) # base64 encoded app data bytes
rssi = IntegerField(null=True) rssi = IntegerField(null=True)
snr = FloatField(null=True) snr = FloatField(null=True)
@@ -82,7 +95,6 @@ class Announce(BaseModel):
class CustomDestinationDisplayName(BaseModel): class CustomDestinationDisplayName(BaseModel):
id = BigAutoField() id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash destination_hash = CharField(unique=True) # unique destination hash
display_name = CharField() # custom display name for the destination hash display_name = CharField() # custom display name for the destination hash
@@ -95,37 +107,31 @@ 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()
hash = CharField(unique=True) # unique lxmf message hash hash = CharField(unique=True) # unique lxmf message hash
source_hash = CharField(index=True) source_hash = CharField(index=True)
destination_hash = CharField(index=True) destination_hash = CharField(index=True)
state = CharField() # state is converted from internal int to a human friendly string state = (
CharField()
) # state is converted from internal int to a human friendly string
progress = FloatField() # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places) progress = FloatField() # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
is_incoming = BooleanField() # if true, we should ignore state, it's set to draft by default on incoming messages is_incoming = BooleanField() # if true, we should ignore state, it's set to draft by default on incoming messages
method = CharField(null=True) # what method is being used to send the message, e.g: direct, propagated method = CharField(
delivery_attempts = IntegerField(default=0) # how many times delivery has been attempted for this message null=True
next_delivery_attempt_at = FloatField(null=True) # timestamp of when the message will attempt delivery again ) # what method is being used to send the message, e.g: direct, propagated
delivery_attempts = IntegerField(
default=0
) # how many times delivery has been attempted for this message
next_delivery_attempt_at = FloatField(
null=True
) # timestamp of when the message will attempt delivery again
title = TextField() title = TextField()
content = TextField() content = TextField()
fields = TextField() # json string fields = TextField() # json string
timestamp = FloatField() # timestamp of when the message was originally created (before ever being sent) timestamp = (
FloatField()
) # timestamp of when the message was originally created (before ever being sent)
rssi = IntegerField(null=True) rssi = IntegerField(null=True)
snr = FloatField(null=True) snr = FloatField(null=True)
quality = FloatField(null=True) quality = FloatField(null=True)
@@ -138,7 +144,6 @@ class LxmfMessage(BaseModel):
class LxmfConversationReadState(BaseModel): class LxmfConversationReadState(BaseModel):
id = BigAutoField() id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash destination_hash = CharField(unique=True) # unique destination hash
last_read_at = DateTimeField() last_read_at = DateTimeField()
@@ -152,12 +157,13 @@ class LxmfConversationReadState(BaseModel):
class LxmfUserIcon(BaseModel): class LxmfUserIcon(BaseModel):
id = BigAutoField() id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash destination_hash = CharField(unique=True) # unique destination hash
icon_name = CharField() # material design icon name for the destination hash icon_name = CharField() # material design icon name for the destination hash
foreground_colour = CharField() # hex colour to use for foreground (icon colour) foreground_colour = CharField() # hex colour to use for foreground (icon colour)
background_colour = CharField() # hex colour to use for background (background colour) background_colour = (
CharField()
) # hex colour to use for background (background colour)
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))

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

@@ -0,0 +1,8 @@
[Desktop Entry]
Name=Reticulum MeshChat
Comment=Decentralized chat over Reticulum networks
Exec=reticulum-meshchat
Icon=icon
Terminal=false
Type=Application
Categories=Network;Chat;

View File

@@ -0,0 +1,28 @@
{
"app-id": "com.liamcottle.reticulummeshchat",
"runtime": "org.freedesktop.Platform",
"runtime-version": "23.08",
"sdk": "org.freedesktop.Sdk",
"command": "reticulum-meshchat",
"finish-args": [
"--share=network",
"--socket=x11",
"--socket=wayland",
"--device=all"
],
"modules": [
{
"name": "reticulum-meshchat",
"buildsystem": "simple",
"build-commands": [
"install -Dm755 dist/ReticulumMeshChat-v$FLATPAK_APP_VERSION-linux.AppImage /app/bin/reticulum-meshchat"
],
"sources": [
{
"type": "file",
"path": "dist/ReticulumMeshChat-v$FLATPAK_APP_VERSION-linux.AppImage"
}
]
}
]
}

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

File diff suppressed because it is too large Load Diff

1278
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.32.0", "version": "1.23.1",
"description": "", "description": "",
"main": "electron/main.js", "main": "electron/main.js",
"scripts": { "scripts": {
@@ -14,10 +14,10 @@
}, },
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=22"
}, },
"devDependencies": { "devDependencies": {
"electron": "^35.7.5", "electron": "^30.0.8",
"electron-builder": "^24.6.3" "electron-builder": "^24.6.3"
}, },
"build": { "build": {
@@ -72,9 +72,10 @@
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}", "artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
"target": [ "target": [
"AppImage", "AppImage",
"deb" "flatpak"
], ],
"maintainer": "Liam Cottle <liam@liamcottle.com>", "category": "Network",
"icon": "electron/build/icon.png",
"extraFiles": [ "extraFiles": [
{ {
"from": "build/exe", "from": "build/exe",
@@ -95,28 +96,36 @@
"artifactName": "ReticulumMeshChat-v${version}-${os}-installer.${ext}", "artifactName": "ReticulumMeshChat-v${version}-${os}-installer.${ext}",
"oneClick": false, "oneClick": false,
"allowToChangeInstallationDirectory": true "allowToChangeInstallationDirectory": true
},
"flatpak": {
"finish-args": [
"--share=network",
"--socket=x11",
"--socket=wayland",
"--device=all"
]
} }
}, },
"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.12.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.4.1", "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,7 +1,6 @@
aiohttp>=3.12.14 aiohttp>=3.11.18
cx_freeze>=7.0.0 cx_freeze>=7.0.0
lxmf>=0.9.2 lxmf>=0.6.3
peewee>=3.18.1 peewee>=3.18.1
psutil>=7.1.3 rns>=0.9.5
rns>=1.0.2 websockets>=15.0.1
websockets>=14.2

View File

@@ -1,44 +1,44 @@
from cx_Freeze import setup, Executable from cx_Freeze import setup, Executable
setup( setup(
name='ReticulumMeshChat', name="ReticulumMeshChat",
version='1.0.0', version="1.0.0",
description='A simple mesh network communications app powered by the Reticulum Network Stack', description="A simple mesh network communications app powered by the Reticulum Network Stack",
executables=[ executables=[
Executable( Executable(
script='meshchat.py', # this script to run script="meshchat.py", # this script to run
base=None, # we are running a console application, not a gui base=None, # we are running a console application, not a gui
target_name='ReticulumMeshChat', # creates ReticulumMeshChat.exe target_name="ReticulumMeshChat", # creates ReticulumMeshChat.exe
shortcut_name='ReticulumMeshChat', # name shown in shortcut shortcut_name="ReticulumMeshChat", # name shown in shortcut
shortcut_dir='ProgramMenuFolder', # put the shortcut in windows start menu shortcut_dir="ProgramMenuFolder", # put the shortcut in windows start menu
icon='logo/icon.ico', # set the icon for the exe icon="logo/icon.ico", # set the icon for the exe
copyright='Copyright (c) 2024 Liam Cottle', copyright="Copyright (c) 2024 Liam Cottle",
), ),
], ],
options={ options={
'build_exe': { "build_exe": {
# libs that are required # libs that are required
'packages': [ "packages": [
# required for dynamic import fix # required for dynamic import fix
# https://github.com/marcelotduarte/cx_Freeze/discussions/2039 # https://github.com/marcelotduarte/cx_Freeze/discussions/2039
# https://github.com/marcelotduarte/cx_Freeze/issues/2041 # https://github.com/marcelotduarte/cx_Freeze/issues/2041
'RNS', "RNS",
], ],
# files that are required # files that are required
'include_files': [ "include_files": [
'package.json', # used to determine app version from python "package.json", # used to determine app version from python
'public/', # static files served by web server "public/", # static files served by web server
], ],
# slim down the build by excluding these unused libs # slim down the build by excluding these unused libs
'excludes': [ "excludes": [
'PIL', # saves ~200MB "PIL", # saves ~200MB
], ],
# this has the same effect as the -O command line option when executing CPython directly. # this has the same effect as the -O command line option when executing CPython directly.
# it also prevents assert statements from executing, removes docstrings and sets __debug__ to False. # it also prevents assert statements from executing, removes docstrings and sets __debug__ to False.
# https://stackoverflow.com/a/57948104 # https://stackoverflow.com/a/57948104
"optimize": 2, "optimize": 2,
# change where exe is built to # change where exe is built to
'build_exe': 'build/exe', "build_exe": "build/exe",
}, },
}, },
) )

View File

@@ -7,7 +7,6 @@ import sys
# this class forces stream writes to be flushed immediately # this class forces stream writes to be flushed immediately
class ImmediateFlushingStreamWrapper: class ImmediateFlushingStreamWrapper:
def __init__(self, stream): def __init__(self, stream):
self.stream = stream self.stream = stream

View File

@@ -1,16 +1,23 @@
# an announce handler that forwards announces to a provided callback for the provided aspect filter # an announce handler that forwards announces to a provided callback for the provided aspect filter
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself # this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
class AnnounceHandler: class AnnounceHandler:
def __init__(self, aspect_filter: str, received_announce_callback): def __init__(self, aspect_filter: str, received_announce_callback):
self.aspect_filter = aspect_filter self.aspect_filter = aspect_filter
self.received_announce_callback = received_announce_callback self.received_announce_callback = received_announce_callback
# we will just pass the received announce back to the provided callback # we will just pass the received announce back to the provided callback
def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash): def received_announce(
self, destination_hash, announced_identity, app_data, announce_packet_hash
):
try: try:
# handle received announce # handle received announce
self.received_announce_callback(self.aspect_filter, destination_hash, announced_identity, app_data, announce_packet_hash) self.received_announce_callback(
self.aspect_filter,
destination_hash,
announced_identity,
app_data,
announce_packet_hash,
)
except: except:
# ignore failure to handle received announce # ignore failure to handle received announce
pass pass

View File

@@ -1,25 +1,23 @@
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):
# 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
# run provided coroutine on main event loop, ensuring thread safety # if there is an existing event loop running, submit the coroutine to that loop
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running(): if existing_event_loop and existing_event_loop.is_running():
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop) 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

@@ -13,7 +13,6 @@ class CallFailedException(Exception):
class AudioCall: class AudioCall:
def __init__(self, link: RNS.Link, is_outbound: bool): def __init__(self, link: RNS.Link, is_outbound: bool):
self.link = link self.link = link
self.is_outbound = is_outbound self.is_outbound = is_outbound
@@ -41,21 +40,25 @@ class AudioCall:
# handle packet received over link # handle packet received over link
def on_packet(self, message, packet): def on_packet(self, message, packet):
# send audio received from call initiator to all audio packet listeners # send audio received from call initiator to all audio packet listeners
for audio_packet_listener in self.audio_packet_listeners: for audio_packet_listener in self.audio_packet_listeners:
audio_packet_listener(message) audio_packet_listener(message)
# send an audio packet over the link # send an audio packet over the link
def send_audio_packet(self, data): def send_audio_packet(self, data):
# do nothing if link is not active # do nothing if link is not active
if self.is_active() is False: if self.is_active() is False:
return return
# drop audio packet if it is too big to send # drop audio packet if it is too big to send
if len(data) > RNS.Link.MDU: if len(data) > RNS.Link.MDU:
print("[AudioCall] dropping audio packet " + str(len(data)) + " bytes exceeds the link packet MDU of " + str(RNS.Link.MDU) + " bytes") print(
"[AudioCall] dropping audio packet "
+ str(len(data))
+ " bytes exceeds the link packet MDU of "
+ str(RNS.Link.MDU)
+ " bytes"
)
return return
# send codec2 audio received from call receiver to call initiator over reticulum link # send codec2 audio received from call receiver to call initiator over reticulum link
@@ -77,9 +80,7 @@ class AudioCall:
class AudioCallManager: class AudioCallManager:
def __init__(self, identity: RNS.Identity): def __init__(self, identity: RNS.Identity):
self.identity = identity self.identity = identity
self.on_incoming_call_callback = None self.on_incoming_call_callback = None
self.on_outgoing_call_callback = None self.on_outgoing_call_callback = None
@@ -91,7 +92,10 @@ class AudioCallManager:
# announces the audio call destination # announces the audio call destination
def announce(self, app_data=None): def announce(self, app_data=None):
self.audio_call_receiver.destination.announce(app_data) self.audio_call_receiver.destination.announce(app_data)
print("[AudioCallManager] announced destination: " + RNS.prettyhexrep(self.audio_call_receiver.destination.hash)) print(
"[AudioCallManager] announced destination: "
+ RNS.prettyhexrep(self.audio_call_receiver.destination.hash)
)
# set the callback for incoming calls # set the callback for incoming calls
def register_incoming_call_callback(self, callback): def register_incoming_call_callback(self, callback):
@@ -103,7 +107,6 @@ class AudioCallManager:
# handle incoming calls from audio call receiver # handle incoming calls from audio call receiver
def handle_incoming_call(self, audio_call: AudioCall): def handle_incoming_call(self, audio_call: AudioCall):
# remember it # remember it
self.audio_calls.append(audio_call) self.audio_calls.append(audio_call)
@@ -113,7 +116,6 @@ class AudioCallManager:
# handle outgoing calls # handle outgoing calls
def handle_outgoing_call(self, audio_call: AudioCall): def handle_outgoing_call(self, audio_call: AudioCall):
# remember it # remember it
self.audio_calls.append(audio_call) self.audio_calls.append(audio_call)
@@ -145,19 +147,22 @@ class AudioCallManager:
return None return None
# attempts to initiate a call to the provided destination and returns the link hash on success # attempts to initiate a call to the provided destination and returns the link hash on success
async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15) -> AudioCall: async def initiate(
self, destination_hash: bytes, timeout_seconds: int = 15
) -> AudioCall:
# determine when to timeout # determine when to timeout
timeout_after_seconds = time.time() + timeout_seconds timeout_after_seconds = time.time() + timeout_seconds
# check if we have a path to the destination # check if we have a path to the destination
if not RNS.Transport.has_path(destination_hash): if not RNS.Transport.has_path(destination_hash):
# we don't have a path, so we need to request it # we don't have a path, so we need to request it
RNS.Transport.request_path(destination_hash) RNS.Transport.request_path(destination_hash)
# wait until we have a path, or give up after the configured timeout # wait until we have a path, or give up after the configured timeout
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after_seconds: while (
not RNS.Transport.has_path(destination_hash)
and time.time() < timeout_after_seconds
):
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# if we still don't have a path, we can't establish a link, so bail out # if we still don't have a path, we can't establish a link, so bail out
@@ -171,14 +176,16 @@ class AudioCallManager:
RNS.Destination.OUT, RNS.Destination.OUT,
RNS.Destination.SINGLE, RNS.Destination.SINGLE,
"call", "call",
"audio" "audio",
) )
# create link # create link
link = RNS.Link(server_destination) link = RNS.Link(server_destination)
# wait until we have established a link, or give up after the configured timeout # wait until we have established a link, or give up after the configured timeout
while link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds: while (
link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds
):
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# if we still haven't established a link, bail out # if we still haven't established a link, bail out
@@ -198,9 +205,7 @@ class AudioCallManager:
class AudioCallReceiver: class AudioCallReceiver:
def __init__(self, manager: AudioCallManager): def __init__(self, manager: AudioCallManager):
self.manager = manager self.manager = manager
# create destination for receiving audio calls # create destination for receiving audio calls
@@ -224,7 +229,6 @@ class AudioCallReceiver:
# client connected to us, set up an audio call instance # client connected to us, set up an audio call instance
def client_connected(self, link: RNS.Link): def client_connected(self, link: RNS.Link):
# todo: this can be optional, it's only being sent by default for ui, can be removed # todo: this can be optional, it's only being sent by default for ui, can be removed
link.identify(self.manager.identity) link.identify(self.manager.identity)

View File

@@ -1,10 +1,8 @@
class ColourUtils: class ColourUtils:
@staticmethod @staticmethod
def hex_colour_to_byte_array(hex_colour): def hex_colour_to_byte_array(hex_colour):
# remove leading "#" # remove leading "#"
hex_colour = hex_colour.lstrip('#') hex_colour = hex_colour.lstrip("#")
# convert the remaining hex string to bytes # convert the remaining hex string to bytes
return bytes.fromhex(hex_colour) return bytes.fromhex(hex_colour)

View File

@@ -2,10 +2,8 @@ import RNS.vendor.configobj
class InterfaceConfigParser: class InterfaceConfigParser:
@staticmethod @staticmethod
def parse(text): def parse(text):
# get lines from provided text # get lines from provided text
lines = text.splitlines() lines = text.splitlines()
@@ -22,7 +20,6 @@ class InterfaceConfigParser:
# process interfaces # process interfaces
interfaces = [] interfaces = []
for interface_name in config_interfaces: for interface_name in config_interfaces:
# ensure interface has a name # ensure interface has a name
interface_config = config_interfaces[interface_name] interface_config = config_interfaces[interface_name]
interface_config["name"] = interface_name interface_config["name"] = interface_name

View File

@@ -1,8 +1,6 @@
class InterfaceEditor: class InterfaceEditor:
@staticmethod @staticmethod
def update_value(interface_details: dict, data: dict, key: str): def update_value(interface_details: dict, data: dict, key: str):
# update value if provided and not empty # update value if provided and not empty
value = data.get(key) value = data.get(key)
if value is not None and value != "": if value is not None and value != "":

View File

@@ -8,7 +8,6 @@ from websockets.sync.connection import Connection
class WebsocketClientInterface(Interface): class WebsocketClientInterface(Interface):
# TODO: required? # TODO: required?
DEFAULT_IFAC_SIZE = 16 DEFAULT_IFAC_SIZE = 16
@@ -18,7 +17,6 @@ class WebsocketClientInterface(Interface):
return f"WebsocketClientInterface[{self.name}/{self.target_url}]" return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
def __init__(self, owner, configuration, websocket: Connection = None): def __init__(self, owner, configuration, websocket: Connection = None):
super().__init__() super().__init__()
self.owner = owner self.owner = owner
@@ -26,8 +24,8 @@ class WebsocketClientInterface(Interface):
self.IN = True self.IN = True
self.OUT = False self.OUT = False
self.HW_MTU = 262144 # 256KiB self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps self.bitrate = 1_000_000_000 # 1Gbps
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
# parse config # parse config
@@ -48,7 +46,6 @@ class WebsocketClientInterface(Interface):
# called when a full packet has been received over the websocket # called when a full packet has been received over the websocket
def process_incoming(self, data): def process_incoming(self, data):
# do nothing if offline or detached # do nothing if offline or detached
if not self.online or self.detached: if not self.online or self.detached:
return return
@@ -65,7 +62,6 @@ class WebsocketClientInterface(Interface):
# the running reticulum transport instance will call this method whenever the interface must transmit a packet # the running reticulum transport instance will call this method whenever the interface must transmit a packet
def process_outgoing(self, data): def process_outgoing(self, data):
# do nothing if offline or detached # do nothing if offline or detached
if not self.online or self.detached: if not self.online or self.detached:
return return
@@ -74,7 +70,9 @@ class WebsocketClientInterface(Interface):
try: try:
self.websocket.send(data) self.websocket.send(data)
except Exception as e: except Exception as e:
RNS.log(f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR) RNS.log(
f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR
)
RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR) RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR)
return return
@@ -87,7 +85,6 @@ class WebsocketClientInterface(Interface):
# connect to the configured websocket server # connect to the configured websocket server
def connect(self): def connect(self):
# do nothing if interface is detached # do nothing if interface is detached
if self.detached: if self.detached:
return return
@@ -95,7 +92,9 @@ class WebsocketClientInterface(Interface):
# connect to websocket server # connect to websocket server
try: try:
RNS.log(f"Connecting to Websocket for {str(self)}...", RNS.LOG_DEBUG) RNS.log(f"Connecting to Websocket for {str(self)}...", RNS.LOG_DEBUG)
self.websocket = connect(f"{self.target_url}", max_size=None, compression=None) self.websocket = connect(
f"{self.target_url}", max_size=None, compression=None
)
RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG) RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG)
self.read_loop() self.read_loop()
except Exception as e: except Exception as e:
@@ -107,7 +106,6 @@ class WebsocketClientInterface(Interface):
self.connect() self.connect()
def read_loop(self): def read_loop(self):
self.online = True self.online = True
try: try:
@@ -119,7 +117,6 @@ class WebsocketClientInterface(Interface):
self.online = False self.online = False
def detach(self): def detach(self):
# mark as offline # mark as offline
self.online = False self.online = False
@@ -130,5 +127,6 @@ class WebsocketClientInterface(Interface):
# mark as detached # mark as detached
self.detached = True self.detached = True
# set interface class RNS should use when importing this external interface # set interface class RNS should use when importing this external interface
interface_class = WebsocketClientInterface interface_class = WebsocketClientInterface

View File

@@ -11,25 +11,25 @@ from src.backend.interfaces.WebsocketClientInterface import WebsocketClientInter
class WebsocketServerInterface(Interface): class WebsocketServerInterface(Interface):
# TODO: required? # TODO: required?
DEFAULT_IFAC_SIZE = 16 DEFAULT_IFAC_SIZE = 16
RESTART_DELAY_SECONDS = 5 RESTART_DELAY_SECONDS = 5
def __str__(self): def __str__(self):
return f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]" return (
f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
)
def __init__(self, owner, configuration): def __init__(self, owner, configuration):
super().__init__() super().__init__()
self.owner = owner self.owner = owner
self.IN = True self.IN = True
self.OUT = False self.OUT = False
self.HW_MTU = 262144 # 256KiB self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps self.bitrate = 1_000_000_000 # 1Gbps
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
self.server: Server | None = None self.server: Server | None = None
@@ -80,17 +80,19 @@ class WebsocketServerInterface(Interface):
pass pass
def serve(self): def serve(self):
# handle new websocket client connections # handle new websocket client connections
def on_websocket_client_connected(websocket: ServerConnection): def on_websocket_client_connected(websocket: ServerConnection):
# create new child interface # create new child interface
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE) RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
spawned_interface = WebsocketClientInterface(self.owner, { spawned_interface = WebsocketClientInterface(
"name": f"Client on {self.name}", self.owner,
"target_host": websocket.remote_address[0], {
"target_port": str(websocket.remote_address[1]), "name": f"Client on {self.name}",
}, websocket=websocket) "target_host": websocket.remote_address[0],
"target_port": str(websocket.remote_address[1]),
},
websocket=websocket,
)
# configure child interface # configure child interface
spawned_interface.IN = self.IN spawned_interface.IN = self.IN
@@ -110,7 +112,10 @@ class WebsocketServerInterface(Interface):
# todo announce rates? # todo announce rates?
# activate child interface # activate child interface
RNS.log(f"Spawned new WebsocketClientInterface: {spawned_interface}", RNS.LOG_VERBOSE) RNS.log(
f"Spawned new WebsocketClientInterface: {spawned_interface}",
RNS.LOG_VERBOSE,
)
RNS.Transport.interfaces.append(spawned_interface) RNS.Transport.interfaces.append(spawned_interface)
# associate child interface with this interface # associate child interface with this interface
@@ -127,7 +132,12 @@ class WebsocketServerInterface(Interface):
# run websocket server # run websocket server
try: try:
RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG) RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG)
with serve(on_websocket_client_connected, self.listen_ip, self.listen_port, compression=None) as server: with serve(
on_websocket_client_connected,
self.listen_ip,
self.listen_port,
compression=None,
) as server:
self.online = True self.online = True
self.server = server self.server = server
server.serve_forever() server.serve_forever()
@@ -141,7 +151,6 @@ class WebsocketServerInterface(Interface):
self.serve() self.serve()
def detach(self): def detach(self):
# mark as offline # mark as offline
self.online = False self.online = False
@@ -152,5 +161,6 @@ class WebsocketServerInterface(Interface):
# mark as detached # mark as detached
self.detached = True self.detached = True
# set interface class RNS should use when importing this external interface # set interface class RNS should use when importing this external interface
interface_class = WebsocketServerInterface interface_class = WebsocketServerInterface

View File

@@ -3,7 +3,6 @@ from typing import List
# helper class for passing around an lxmf audio field # helper class for passing around an lxmf audio field
class LxmfAudioField: class LxmfAudioField:
def __init__(self, audio_mode: int, audio_bytes: bytes): def __init__(self, audio_mode: int, audio_bytes: bytes):
self.audio_mode = audio_mode self.audio_mode = audio_mode
self.audio_bytes = audio_bytes self.audio_bytes = audio_bytes
@@ -11,7 +10,6 @@ class LxmfAudioField:
# helper class for passing around an lxmf image field # helper class for passing around an lxmf image field
class LxmfImageField: class LxmfImageField:
def __init__(self, image_type: str, image_bytes: bytes): def __init__(self, image_type: str, image_bytes: bytes):
self.image_type = image_type self.image_type = image_type
self.image_bytes = image_bytes self.image_bytes = image_bytes
@@ -19,7 +17,6 @@ class LxmfImageField:
# helper class for passing around an lxmf file attachment # helper class for passing around an lxmf file attachment
class LxmfFileAttachment: class LxmfFileAttachment:
def __init__(self, file_name: str, file_bytes: bytes): def __init__(self, file_name: str, file_bytes: bytes):
self.file_name = file_name self.file_name = file_name
self.file_bytes = file_bytes self.file_bytes = file_bytes
@@ -27,7 +24,5 @@ class LxmfFileAttachment:
# helper class for passing around an lxmf file attachments field # helper class for passing around an lxmf file attachments field
class LxmfFileAttachmentsField: class LxmfFileAttachmentsField:
def __init__(self, file_attachments: List[LxmfFileAttachment]): def __init__(self, file_attachments: List[LxmfFileAttachment]):
self.file_attachments = file_attachments self.file_attachments = file_attachments

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">
@@ -64,141 +64,6 @@
</div> </div>
</div> </div>
<!-- system resources -->
<div v-if="appInfo && appInfo.memory_usage" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
System Resources
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Live
</span>
</div>
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
<!-- memory usage -->
<div class="flex p-1">
<div class="mr-auto">
<div>Memory Usage (RSS)</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.memory_usage.rss) }}</div>
</div>
</div>
<!-- virtual memory -->
<div class="flex p-1">
<div class="mr-auto">
<div>Virtual Memory Size</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.memory_usage.vms) }}</div>
</div>
</div>
</div>
</div>
<!-- network statistics -->
<div v-if="appInfo && appInfo.network_stats" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
Network Statistics
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Live
</span>
</div>
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
<!-- bytes sent -->
<div class="flex p-1">
<div class="mr-auto">
<div>Data Sent</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.network_stats.bytes_sent) }}</div>
</div>
</div>
<!-- bytes received -->
<div class="flex p-1">
<div class="mr-auto">
<div>Data Received</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.network_stats.bytes_recv) }}</div>
</div>
</div>
<!-- packets sent -->
<div class="p-1">
<div>Packets Sent</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.network_stats.packets_sent) }}</div>
</div>
<!-- packets received -->
<div class="p-1">
<div>Packets Received</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.network_stats.packets_recv) }}</div>
</div>
</div>
</div>
<!-- reticulum statistics -->
<div v-if="appInfo && appInfo.reticulum_stats" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
Reticulum Statistics
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Live
</span>
</div>
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
<!-- total paths -->
<div class="p-1">
<div>Total Paths</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.total_paths) }}</div>
</div>
<!-- announces per second -->
<div class="p-1">
<div>Announces per Second</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.announces_per_second) }}</div>
</div>
<!-- announces per minute -->
<div class="p-1">
<div>Announces per Minute</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.announces_per_minute) }}</div>
</div>
<!-- announces per hour -->
<div class="p-1">
<div>Announces per Hour</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.announces_per_hour) }}</div>
</div>
</div>
</div>
<!-- download statistics -->
<div v-if="appInfo && appInfo.download_stats" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
Download Statistics
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Live
</span>
</div>
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
<!-- average download speed -->
<div class="p-1">
<div>Average Download Speed</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">
<span v-if="appInfo.download_stats.avg_download_speed_bps !== null">
{{ formatBytesPerSecond(appInfo.download_stats.avg_download_speed_bps) }}
</span>
<span v-else>No downloads yet</span>
</div>
</div>
</div>
</div>
<!-- reticulum status --> <!-- reticulum status -->
<div v-if="appInfo" class="bg-white dark:bg-zinc-900 rounded shadow"> <div v-if="appInfo" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">Reticulum Status</div> <div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">Reticulum Status</div>
@@ -261,21 +126,11 @@ export default {
return { return {
appInfo: null, appInfo: null,
config: null, config: null,
updateInterval: null,
}; };
}, },
mounted() { mounted() {
this.getAppInfo(); this.getAppInfo();
this.getConfig(); this.getConfig();
// Update stats every 5 seconds
this.updateInterval = setInterval(() => {
this.getAppInfo();
}, 5000);
},
beforeUnmount() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
}, },
methods: { methods: {
async getAppInfo() { async getAppInfo() {
@@ -311,12 +166,6 @@ export default {
formatBytes: function(bytes) { formatBytes: function(bytes) {
return Utils.formatBytes(bytes); return Utils.formatBytes(bytes);
}, },
formatNumber: function(num) {
return Utils.formatNumber(num);
},
formatBytesPerSecond: function(bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
}, },
computed: { computed: {
isElectron() { isElectron() {

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>
@@ -124,12 +85,7 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
</div> </div>
<div class="my-auto"> <div class="my-auto">Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)</div>
Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)
<span v-if="nodeFileDownloadSpeed !== null" class="ml-2 text-sm">
- {{ formatBytesPerSecond(nodeFileDownloadSpeed) }}
</span>
</div>
</div> </div>
</div> </div>
@@ -184,7 +140,6 @@ import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection"; import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue"; import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
import GlobalEmitter from "../../js/GlobalEmitter"; import GlobalEmitter from "../../js/GlobalEmitter";
import Utils from "../../js/Utils";
export default { export default {
name: 'NomadNetworkPage', name: 'NomadNetworkPage',
@@ -197,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",
@@ -219,10 +170,6 @@ export default {
isDownloadingNodeFile: false, isDownloadingNodeFile: false,
nodeFilePath: null, nodeFilePath: null,
nodeFileProgress: 0, nodeFileProgress: 0,
nodeFileDownloadStartTime: null,
nodeFileLastProgressTime: null,
nodeFileLastProgressValue: 0,
nodeFileDownloadSpeed: null,
nomadnetPageDownloadCallbacks: {}, nomadnetPageDownloadCallbacks: {},
nomadnetFileDownloadCallbacks: {}, nomadnetFileDownloadCallbacks: {},
@@ -231,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);
@@ -257,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) {
@@ -370,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 {
@@ -425,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
}, },
}); });
@@ -766,72 +656,26 @@ export default {
this.isDownloadingNodeFile = true; this.isDownloadingNodeFile = true;
this.nodeFilePath = parsedUrl.path.split("/").pop(); this.nodeFilePath = parsedUrl.path.split("/").pop();
this.nodeFileProgress = 0; this.nodeFileProgress = 0;
this.nodeFileDownloadStartTime = Date.now();
this.nodeFileLastProgressTime = Date.now();
this.nodeFileLastProgressValue = 0;
this.nodeFileDownloadSpeed = null;
// start file download // start file download
this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => { this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => {
// Calculate final download speed based on actual file size
if (this.nodeFileDownloadStartTime) {
const totalTime = (Date.now() - this.nodeFileDownloadStartTime) / 1000; // seconds
const fileSizeBytes = atob(fileBytesBase64).length;
if (totalTime > 0) {
this.nodeFileDownloadSpeed = fileSizeBytes / totalTime;
}
}
// no longer downloading // no longer downloading
this.isDownloadingNodeFile = false; this.isDownloadingNodeFile = false;
// download file to browser // download file to browser
this.downloadFileFromBase64(fileName, fileBytesBase64); this.downloadFileFromBase64(fileName, fileBytesBase64);
// Clear speed after a moment
setTimeout(() => {
this.nodeFileDownloadSpeed = null;
}, 2000);
}, (failureReason) => { }, (failureReason) => {
// no longer downloading // no longer downloading
this.isDownloadingNodeFile = false; this.isDownloadingNodeFile = false;
this.nodeFileDownloadSpeed = null;
// show error message // show error message
DialogUtils.alert(`Failed to download file: ${failureReason}`); DialogUtils.alert(`Failed to download file: ${failureReason}`);
}, (progress) => { }, (progress) => {
const currentTime = Date.now(); this.nodeFileProgress = Math.round(progress * 100);
const progressValue = progress;
this.nodeFileProgress = Math.round(progressValue * 100);
// Calculate estimated download speed based on progress rate
if (this.nodeFileDownloadStartTime && progressValue > 0) {
const elapsedTime = (currentTime - this.nodeFileDownloadStartTime) / 1000; // seconds
if (elapsedTime > 0.5) { // Only calculate after at least 0.5 seconds
// Estimate total file size based on progress rate
// If we've downloaded progressValue in elapsedTime, estimate total time
const estimatedTotalTime = elapsedTime / progressValue;
// Estimate file size based on average download speed assumption
// We'll refine this when download completes with actual size
// For now, estimate based on typical mesh network file sizes (100KB-10MB range)
// Use a conservative estimate that will be updated when download completes
const estimatedFileSize = 500 * 1024; // Start with 500KB estimate
const estimatedBytesDownloaded = estimatedFileSize * progressValue;
const estimatedSpeed = estimatedBytesDownloaded / elapsedTime;
// Only update if we have a reasonable estimate
if (estimatedSpeed > 0 && estimatedSpeed < 100 * 1024 * 1024) { // Cap at 100MB/s
this.nodeFileDownloadSpeed = estimatedSpeed;
}
}
}
this.nodeFileLastProgressTime = currentTime;
this.nodeFileLastProgressValue = progressValue;
}); });
return; return;
@@ -885,9 +729,6 @@ export default {
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000); setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
}, },
formatBytesPerSecond: function(bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
onNodeClick: function(node) { onNodeClick: function(node) {
// update selected node // update selected node
@@ -896,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() {
@@ -966,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){
@@ -25,13 +18,6 @@ class Utils {
} }
static formatNumber(num) {
if(num === 0){
return '0';
}
return num.toLocaleString();
}
static parseSeconds(secondsToFormat) { static parseSeconds(secondsToFormat) {
secondsToFormat = Number(secondsToFormat); secondsToFormat = Number(secondsToFormat);
var days = Math.floor(secondsToFormat / (3600 * 24)); var days = Math.floor(secondsToFormat / (3600 * 24));
@@ -134,22 +120,6 @@ class Utils {
} }
static formatBytesPerSecond(bytesPerSecond) {
if(bytesPerSecond === 0 || bytesPerSecond == null){
return '0 B/s';
}
const k = 1024;
const decimals = 1;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
}
static formatFrequency(hz) { static formatFrequency(hz) {
if(hz === 0 || hz == null){ if(hz === 0 || hz == null){