0.1.0
This commit is contained in:
77
.dockerignore
Normal file
77
.dockerignore
Normal file
@@ -0,0 +1,77 @@
|
||||
# Documentation
|
||||
README.md
|
||||
LICENSE
|
||||
donate.md
|
||||
screenshots/
|
||||
docs/
|
||||
|
||||
# Development files
|
||||
.github/
|
||||
electron/
|
||||
scripts/
|
||||
Makefile
|
||||
|
||||
# Build artifacts and cache
|
||||
build/
|
||||
dist/
|
||||
public/
|
||||
meshchatx/public/
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
*.egg
|
||||
python-dist/
|
||||
|
||||
# Virtual environments
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.venv/
|
||||
|
||||
# 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
|
||||
|
||||
# Local storage and runtime data
|
||||
storage/
|
||||
testing/
|
||||
telemetry_test_lxmf/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
20
.gitea/workflows/bearer-pr.yml
Normal file
20
.gitea/workflows/bearer-pr.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
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
|
||||
343
.gitea/workflows/build.yml
Normal file
343
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,343 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_windows:
|
||||
description: 'Build Windows'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: boolean
|
||||
build_mac:
|
||||
description: 'Build macOS'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: boolean
|
||||
build_linux:
|
||||
description: 'Build Linux'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: boolean
|
||||
build_docker:
|
||||
description: 'Build Docker'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build_frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||
|
||||
- name: Install NodeJS
|
||||
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Sync versions
|
||||
run: python scripts/sync_version.py
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install NodeJS Deps
|
||||
run: pnpm install
|
||||
|
||||
- name: Build Frontend
|
||||
run: pnpm run build-frontend
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: meshchatx/public
|
||||
if-no-files-found: error
|
||||
|
||||
build_desktop:
|
||||
name: Build Desktop (${{ matrix.name }})
|
||||
needs: build_frontend
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
node: 22
|
||||
python: "3.13"
|
||||
release_artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
|
||||
build_input: build_windows
|
||||
dist_script: dist-prebuilt
|
||||
variant: standard
|
||||
electron_version: "39.2.4"
|
||||
- name: mac
|
||||
os: macos-14
|
||||
node: 22
|
||||
python: "3.13"
|
||||
release_artifacts: "dist/*-mac-*.dmg"
|
||||
build_input: build_mac
|
||||
dist_script: dist:mac-universal
|
||||
variant: standard
|
||||
electron_version: "39.2.4"
|
||||
- name: linux
|
||||
os: ubuntu-latest
|
||||
node: 22
|
||||
python: "3.13"
|
||||
release_artifacts: "dist/*-linux.AppImage,dist/*-linux.deb,python-dist/*.whl"
|
||||
build_input: build_linux
|
||||
dist_script: dist-prebuilt
|
||||
variant: standard
|
||||
electron_version: "39.2.4"
|
||||
- name: windows-legacy
|
||||
os: windows-latest
|
||||
node: 18
|
||||
python: "3.11"
|
||||
release_artifacts: "dist/*-win-installer*.exe,dist/*-win-portable*.exe"
|
||||
build_input: build_windows
|
||||
dist_script: dist-prebuilt
|
||||
variant: legacy
|
||||
electron_version: "30.0.8"
|
||||
- name: linux-legacy
|
||||
os: ubuntu-latest
|
||||
node: 18
|
||||
python: "3.11"
|
||||
release_artifacts: "dist/*-linux*.AppImage,dist/*-linux*.deb,python-dist/*.whl"
|
||||
build_input: build_linux
|
||||
dist_script: dist-prebuilt
|
||||
variant: legacy
|
||||
electron_version: "30.0.8"
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||
|
||||
- name: Set legacy Electron version
|
||||
if: |
|
||||
matrix.variant == 'legacy' &&
|
||||
(github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true))
|
||||
shell: bash
|
||||
run: |
|
||||
node -e "const fs=require('fs');const pkg=require('./package.json');pkg.devDependencies.electron='${{ matrix.electron_version }}';fs.writeFileSync('package.json', JSON.stringify(pkg,null,2));"
|
||||
if [ -f pnpm-lock.yaml ]; then rm pnpm-lock.yaml; fi
|
||||
|
||||
- name: Install NodeJS
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install Python
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
|
||||
- name: Install Poetry
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
run: python -m pip install --upgrade pip poetry
|
||||
|
||||
- name: Sync versions
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
run: python scripts/sync_version.py
|
||||
|
||||
- name: Install Python Deps
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
run: python -m poetry install
|
||||
|
||||
- name: Install pnpm
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install NodeJS Deps
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
run: pnpm install
|
||||
|
||||
- name: Prepare frontend directory
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
run: python scripts/prepare_frontend_dir.py
|
||||
|
||||
- name: Download frontend artifact
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: frontend-build
|
||||
path: meshchatx/public
|
||||
|
||||
- name: Install patchelf
|
||||
if: |
|
||||
startsWith(matrix.name, 'linux') &&
|
||||
(github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true))
|
||||
run: sudo apt-get update && sudo apt-get install -y patchelf
|
||||
|
||||
- name: Build Python wheel
|
||||
if: |
|
||||
startsWith(matrix.name, 'linux') &&
|
||||
(github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true))
|
||||
run: |
|
||||
python -m poetry build -f wheel
|
||||
mkdir -p python-dist
|
||||
mv dist/*.whl python-dist/
|
||||
rm -rf dist
|
||||
|
||||
- name: Build Electron App (Universal)
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
run: pnpm run ${{ matrix.dist_script }}
|
||||
|
||||
- name: Rename artifacts for legacy build
|
||||
if: |
|
||||
matrix.variant == 'legacy' &&
|
||||
(github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true))
|
||||
run: ./scripts/rename_legacy_artifacts.sh
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-${{ matrix.name }}
|
||||
path: |
|
||||
dist/*-win-installer*.exe
|
||||
dist/*-win-portable*.exe
|
||||
dist/*-mac-*.dmg
|
||||
dist/*-linux*.AppImage
|
||||
dist/*-linux*.deb
|
||||
python-dist/*.whl
|
||||
if-no-files-found: ignore
|
||||
|
||||
create_release:
|
||||
name: Create Release
|
||||
needs: build_desktop
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R artifacts
|
||||
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
find artifacts -type f \( -name "*.exe" -o -name "*.dmg" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.whl" \) -exec cp {} release-assets/ \;
|
||||
ls -lh release-assets/
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
run: |
|
||||
cd release-assets
|
||||
echo "## SHA256 Checksums" > release-body.md
|
||||
echo "" >> release-body.md
|
||||
|
||||
for file in *.exe *.dmg *.AppImage *.deb *.whl; do
|
||||
if [ -f "$file" ]; then
|
||||
sha256sum "$file" | tee "${file}.sha256"
|
||||
echo "\`$(cat "${file}.sha256")\`" >> release-body.md
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> release-body.md
|
||||
echo "Individual \`.sha256\` files are included for each artifact." >> release-body.md
|
||||
|
||||
cat release-body.md
|
||||
echo ""
|
||||
echo "Generated .sha256 files:"
|
||||
ls -1 *.sha256 2>/dev/null || echo "No .sha256 files found"
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||
with:
|
||||
draft: true
|
||||
artifacts: "release-assets/*"
|
||||
bodyFile: "release-assets/release-body.md"
|
||||
|
||||
build_docker:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_docker == 'true')
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Set lowercase repository owner
|
||||
run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: >-
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:latest,
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:${{ github.ref_name }}
|
||||
labels: >-
|
||||
org.opencontainers.image.title=Reticulum MeshChatX,
|
||||
org.opencontainers.image.description=Docker image for Reticulum MeshChatX,
|
||||
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchatx/
|
||||
22
.gitea/workflows/dependency-review.yml
Normal file
22
.gitea/workflows/dependency-review.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
name: 'Dependency review'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4
|
||||
with:
|
||||
comment-summary-in-pr: always
|
||||
57
.gitignore
vendored
Normal file
57
.gitignore
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
# IDE and editor files
|
||||
.idea
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.venv/
|
||||
|
||||
# Build files
|
||||
/build/
|
||||
/dist/
|
||||
/meshchatx/public/
|
||||
public/
|
||||
/electron/build/exe/
|
||||
python-dist/
|
||||
|
||||
# Local storage and runtime data
|
||||
storage/
|
||||
testing/
|
||||
telemetry_test_lxmf/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
6
.npmrc
Normal file
6
.npmrc
Normal file
@@ -0,0 +1,6 @@
|
||||
registry=https://registry.npmjs.org/
|
||||
fetch-retries=5
|
||||
fetch-retry-mintimeout=20000
|
||||
fetch-retry-maxtimeout=120000
|
||||
fetch-timeout=300000
|
||||
|
||||
9
.prettierignore
Normal file
9
.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
dist
|
||||
node_modules
|
||||
build
|
||||
electron/assets
|
||||
meshchatx/public
|
||||
pnpm-lock.yaml
|
||||
poetry.lock
|
||||
*.log
|
||||
|
||||
9
.prettierrc
Normal file
9
.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"tabWidth": 4,
|
||||
"singleQuote": false,
|
||||
"printWidth": 120,
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
|
||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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
|
||||
FROM node:${NODE_VERSION}-alpine@${NODE_ALPINE_SHA256} AS build-frontend
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Copy required source files
|
||||
COPY package.json vite.config.js ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
COPY meshchatx ./meshchatx
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Install NodeJS deps, exluding electron
|
||||
RUN pnpm install --prod && \
|
||||
pnpm run build-frontend
|
||||
|
||||
# Main app build
|
||||
FROM python:${PYTHON_VERSION}-alpine@${PYTHON_ALPINE_SHA256}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python deps
|
||||
COPY ./requirements.txt .
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
gcc \
|
||||
musl-dev \
|
||||
linux-headers \
|
||||
python3-dev && \
|
||||
pip install -r requirements.txt && \
|
||||
apk del .build-deps
|
||||
|
||||
# Copy prebuilt frontend
|
||||
COPY --from=build-frontend /src/meshchatx/public meshchatx/public
|
||||
|
||||
# Copy other required source files
|
||||
COPY meshchatx ./meshchatx
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
|
||||
CMD ["python", "-m", "meshchatx.meshchat", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]
|
||||
22
LICENSE
Normal file
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Liam Cottle
|
||||
Copyright (c) 2026 Sudo-Ivan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
40
README.md
40
README.md
@@ -4,40 +4,26 @@ A heavily customized and updated fork of [Reticulum MeshChat](https://github.com
|
||||
|
||||
## Features of this Fork
|
||||
|
||||
| Feature | Description |
|
||||
|-------------------------------------------------------------------|-----------------------------------------------------------------------------|
|
||||
| Custom UI/UX | Modern, improved interface |
|
||||
| Inbound LXMF & local node stamps | Configure inbound messaging and node propogation addresses |
|
||||
| Improved config parsing | More accurate and flexible parsing of config files |
|
||||
| Automatic HTTPS | Generates self-signed certificates for secure access |
|
||||
| Cancelable fetches & downloads | Cancel page fetching or downloading |
|
||||
| Built-in page archiving | Archive pages; background crawler automatically archives nodes that announces|
|
||||
| Translator tool | Translate messages via Argos Translate or LibreTranslate API |
|
||||
| Network visualization | Faster, improved visualization page |
|
||||
| User & node blocking | Block specific users or nodes |
|
||||
| Database insights | Advanced settings and raw database access |
|
||||
| Multi-language support | Internationalization (i18n) provided |
|
||||
| Offline maps (OpenLayers + MBTiles) | Interactive map using OpenLayers with MBTiles offline support |
|
||||
| Extra tools | RNCP, RNStatus, RNProbe, Translator, Message Forwarding |
|
||||
| Major codebase reorganization | Cleaner, refactored architecture |
|
||||
| Better Dependency management | Poetry for Python, PNPM for Node.js packages |
|
||||
| Increased statistics | More network and usage stats (About page) |
|
||||
| Supply Chain Protection | Actions and docker images use full SHA hashes |
|
||||
| Docker optimizations | Smaller sizes, more secure |
|
||||
| Electron improvements | Security, ASAR packaging |
|
||||
| Updated dependencies | Latest PNPM and Python package versions |
|
||||
| Linting & SAST cleanup | Improved code quality and security |
|
||||
| Performance improvements | Faster and more efficient operation |
|
||||
| SQLite backend | Raw SQLite database backend (replaces Peewee ORM) |
|
||||
| Map | OpenLayers and MBTiles support |
|
||||
### Major
|
||||
|
||||
- Full LXST support.
|
||||
- Map (w/ MBTiles support for offline)
|
||||
- Security improvements
|
||||
- Custom UI/UX
|
||||
- More Tools
|
||||
- Built-in page archiving and automatic crawler (no multi-page support yet).
|
||||
- Block LXMF users and NomadNet Nodes
|
||||
- Toast system for notifications
|
||||
- i18n support (En, De, Ru)
|
||||
- Raw SQLite database backend (replaced Peewee ORM)
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Tests and proper CI/CD pipeline.
|
||||
- [ ] RNS hot reload fix
|
||||
- [ ] Backup/Import identities, messages and interfaces.
|
||||
- [ ] Full LXST support.
|
||||
- [ ] Offline Reticulum documentation tool
|
||||
- [ ] LXMF Telemtry for map
|
||||
- [ ] Spam filter (based on keywords)
|
||||
- [ ] Multi-identity support.
|
||||
- [ ] TAK tool/integration
|
||||
|
||||
153
Taskfile.yml
Normal file
153
Taskfile.yml
Normal file
@@ -0,0 +1,153 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
PYTHON:
|
||||
sh: echo "${PYTHON:-python}"
|
||||
NPM:
|
||||
sh: echo "${NPM:-pnpm}"
|
||||
LEGACY_ELECTRON_VERSION:
|
||||
sh: echo "${LEGACY_ELECTRON_VERSION:-30.0.8}"
|
||||
DOCKER_COMPOSE_CMD:
|
||||
sh: echo "${DOCKER_COMPOSE_CMD:-docker compose}"
|
||||
DOCKER_COMPOSE_FILE:
|
||||
sh: echo "${DOCKER_COMPOSE_FILE:-docker-compose.yml}"
|
||||
DOCKER_IMAGE:
|
||||
sh: echo "${DOCKER_IMAGE:-reticulum-meshchatx:local}"
|
||||
DOCKER_BUILDER:
|
||||
sh: echo "${DOCKER_BUILDER:-meshchatx-builder}"
|
||||
DOCKER_PLATFORMS:
|
||||
sh: echo "${DOCKER_PLATFORMS:-linux/amd64}"
|
||||
DOCKER_BUILD_FLAGS:
|
||||
sh: echo "${DOCKER_BUILD_FLAGS:---load}"
|
||||
DOCKER_BUILD_ARGS:
|
||||
sh: echo "${DOCKER_BUILD_ARGS:-}"
|
||||
DOCKER_CONTEXT:
|
||||
sh: echo "${DOCKER_CONTEXT:-.}"
|
||||
DOCKERFILE:
|
||||
sh: echo "${DOCKERFILE:-Dockerfile}"
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: Show available tasks
|
||||
cmds:
|
||||
- task --list
|
||||
|
||||
install:
|
||||
desc: Install all dependencies (syncs version, installs node modules and python deps)
|
||||
deps: [sync-version, node_modules, python]
|
||||
|
||||
node_modules:
|
||||
desc: Install Node.js dependencies
|
||||
cmds:
|
||||
- '{{.NPM}} install'
|
||||
|
||||
python:
|
||||
desc: Install Python dependencies using Poetry
|
||||
cmds:
|
||||
- '{{.PYTHON}} -m poetry install'
|
||||
|
||||
run:
|
||||
desc: Run the application
|
||||
deps: [install]
|
||||
cmds:
|
||||
- '{{.PYTHON}} -m poetry run meshchat'
|
||||
|
||||
develop:
|
||||
desc: Run the application in development mode
|
||||
cmds:
|
||||
- task: run
|
||||
|
||||
build:
|
||||
desc: Build the application (frontend and backend)
|
||||
deps: [install]
|
||||
cmds:
|
||||
- '{{.NPM}} run build'
|
||||
|
||||
build-frontend:
|
||||
desc: Build only the frontend
|
||||
deps: [node_modules]
|
||||
cmds:
|
||||
- '{{.NPM}} run build-frontend'
|
||||
|
||||
wheel:
|
||||
desc: Build Python wheel package
|
||||
deps: [install]
|
||||
cmds:
|
||||
- '{{.PYTHON}} -m poetry build -f wheel'
|
||||
- '{{.PYTHON}} scripts/move_wheels.py'
|
||||
|
||||
build-appimage:
|
||||
desc: Build Linux AppImage
|
||||
deps: [build]
|
||||
cmds:
|
||||
- '{{.NPM}} run electron-postinstall'
|
||||
- '{{.NPM}} run dist -- --linux AppImage'
|
||||
|
||||
build-exe:
|
||||
desc: Build Windows portable executable
|
||||
deps: [build]
|
||||
cmds:
|
||||
- '{{.NPM}} run electron-postinstall'
|
||||
- '{{.NPM}} run dist -- --win portable'
|
||||
|
||||
dist:
|
||||
desc: Build distribution (defaults to AppImage)
|
||||
cmds:
|
||||
- task: build-appimage
|
||||
|
||||
electron-legacy:
|
||||
desc: Install legacy Electron version
|
||||
cmds:
|
||||
- '{{.NPM}} install --no-save electron@{{.LEGACY_ELECTRON_VERSION}}'
|
||||
|
||||
build-appimage-legacy:
|
||||
desc: Build Linux AppImage with legacy Electron version
|
||||
deps: [build, electron-legacy]
|
||||
cmds:
|
||||
- '{{.NPM}} run electron-postinstall'
|
||||
- '{{.NPM}} run dist -- --linux AppImage'
|
||||
- './scripts/rename_legacy_artifacts.sh'
|
||||
|
||||
build-exe-legacy:
|
||||
desc: Build Windows portable executable with legacy Electron version
|
||||
deps: [build, electron-legacy]
|
||||
cmds:
|
||||
- '{{.NPM}} run electron-postinstall'
|
||||
- '{{.NPM}} run dist -- --win portable'
|
||||
- './scripts/rename_legacy_artifacts.sh'
|
||||
|
||||
clean:
|
||||
desc: Clean build artifacts and dependencies
|
||||
cmds:
|
||||
- rm -rf node_modules
|
||||
- rm -rf build
|
||||
- rm -rf dist
|
||||
- rm -rf python-dist
|
||||
- rm -rf meshchatx/public
|
||||
|
||||
sync-version:
|
||||
desc: Sync version numbers across project files
|
||||
cmds:
|
||||
- '{{.PYTHON}} scripts/sync_version.py'
|
||||
|
||||
build-docker:
|
||||
desc: Build Docker image using buildx
|
||||
cmds:
|
||||
- |
|
||||
if ! docker buildx inspect {{.DOCKER_BUILDER}} >/dev/null 2>&1; then
|
||||
docker buildx create --name {{.DOCKER_BUILDER}} --use >/dev/null
|
||||
else
|
||||
docker buildx use {{.DOCKER_BUILDER}}
|
||||
fi
|
||||
- |
|
||||
docker buildx build --builder {{.DOCKER_BUILDER}} --platform {{.DOCKER_PLATFORMS}} \
|
||||
{{.DOCKER_BUILD_FLAGS}} \
|
||||
-t {{.DOCKER_IMAGE}} \
|
||||
{{.DOCKER_BUILD_ARGS}} \
|
||||
-f {{.DOCKERFILE}} \
|
||||
{{.DOCKER_CONTEXT}}
|
||||
|
||||
run-docker:
|
||||
desc: Run Docker container using docker-compose
|
||||
cmds:
|
||||
- 'MESHCHAT_IMAGE="{{.DOCKER_IMAGE}}" {{.DOCKER_COMPOSE_CMD}} -f {{.DOCKER_COMPOSE_FILE}} up --remove-orphans --pull never reticulum-meshchatx'
|
||||
57
cx_setup.py
Normal file
57
cx_setup.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from cx_Freeze import Executable, setup
|
||||
|
||||
from meshchatx.src.version import __version__
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
PUBLIC_DIR = ROOT / "meshchatx" / "public"
|
||||
|
||||
include_files = [
|
||||
(str(PUBLIC_DIR), "public"),
|
||||
("logo", "logo"),
|
||||
]
|
||||
|
||||
packages = [
|
||||
"RNS",
|
||||
"RNS.Interfaces",
|
||||
"LXMF",
|
||||
"LXST",
|
||||
"pycparser",
|
||||
"cffi",
|
||||
"ply",
|
||||
]
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
packages.append("audioop")
|
||||
|
||||
setup(
|
||||
name="ReticulumMeshChatX",
|
||||
version=__version__,
|
||||
description="A simple mesh network communications app powered by the Reticulum Network Stack",
|
||||
executables=[
|
||||
Executable(
|
||||
script="meshchatx/meshchat.py",
|
||||
base=None,
|
||||
target_name="ReticulumMeshChatX",
|
||||
shortcut_name="ReticulumMeshChatX",
|
||||
shortcut_dir="ProgramMenuFolder",
|
||||
icon="logo/icon.ico",
|
||||
),
|
||||
],
|
||||
options={
|
||||
"build_exe": {
|
||||
"packages": packages,
|
||||
"include_files": include_files,
|
||||
"excludes": [
|
||||
"PIL",
|
||||
],
|
||||
"optimize": 1,
|
||||
"build_exe": "build/exe",
|
||||
"replace_paths": [
|
||||
("*", ""),
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
reticulum-meshchatx:
|
||||
container_name: reticulum-meshchatx
|
||||
image: ${MESHCHAT_IMAGE:-ghcr.io/sudo-ivan/reticulum-meshchatx:latest}
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
# Make the meshchat web interface accessible from the host on port 8000
|
||||
ports:
|
||||
- 127.0.0.1:8000:8000
|
||||
volumes:
|
||||
- meshchat-config:/config
|
||||
# Uncomment if you have a USB device connected, such as an RNode
|
||||
# devices:
|
||||
# - /dev/ttyUSB0:/dev/ttyUSB0
|
||||
|
||||
volumes:
|
||||
meshchat-config:
|
||||
31
docs/meshchat_on_android_with_termux.md
Normal file
31
docs/meshchat_on_android_with_termux.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# MeshChat on Android
|
||||
|
||||
It's possible to run on Android from source, using [Termux](https://termux.dev/).
|
||||
|
||||
You will need to install a few extra dependencies and make a change to `requirements.txt`.
|
||||
|
||||
```
|
||||
pkg upgrade
|
||||
pkg install git
|
||||
pkg install nodejs-lts
|
||||
pkg install python-pip
|
||||
pkg install rust
|
||||
pkg install binutils
|
||||
pkg install build-essential
|
||||
```
|
||||
|
||||
You should now be able to follow the [how to use it](../README.md#how-to-use-it) instructions above.
|
||||
|
||||
Before running `pip install -r requirements.txt`, you will need to comment out the `cx_freeze` dependency. It failed to build on my Android tablet, and is not actually required for running from source.
|
||||
|
||||
```
|
||||
nano requirements.txt
|
||||
```
|
||||
|
||||
Ensure the `cx_freeze` line is updated to `#cx_freeze`
|
||||
|
||||
> Note: Building wheel for cryptography may take a while on Android.
|
||||
|
||||
Once MeshChat is running via Termux, open your favourite Android web browser, and navigate to http://localhost:8000
|
||||
|
||||
> Note: The default `AutoInterface` may not work on your Android device. You will need to configure another interface such as `TCPClientInterface`.
|
||||
11
docs/meshchat_on_docker.md
Normal file
11
docs/meshchat_on_docker.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# MeshChat on Docker
|
||||
|
||||
A docker image is automatically built by GitHub actions, and can be downloaded from the GitHub container registry.
|
||||
|
||||
```
|
||||
docker pull ghcr.io/liamcottle/reticulum-meshchat:latest
|
||||
```
|
||||
|
||||
Additionally, an example [docker-compose.yml](../docker-compose.yml) is available.
|
||||
|
||||
The example automatically generates a new reticulum config file in the `meshchat-config` volume. The MeshChat database is also stored in this volume.
|
||||
99
docs/meshchat_on_raspberry_pi.md
Normal file
99
docs/meshchat_on_raspberry_pi.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# MeshChat on a Raspberry Pi
|
||||
|
||||
A simple guide to install [MeshChat](https://github.com/liamcottle/reticulum-meshchat) on a Raspberry Pi.
|
||||
|
||||
This would allow you to connect an [RNode](https://github.com/markqvist/RNode_Firmware) (such as a Heltec v3) to the Rasbperry Pi via USB, and then access the MeshChat Web UI from another machine on your network.
|
||||
|
||||
My intended use case is to run the Pi + RNode combo from my solar-powered shed, and access the MeshChat Web UI via WiFi.
|
||||
|
||||
> Note: This has been tested on a Raspberry Pi 4 Model B
|
||||
|
||||
## Install Raspberry Pi OS
|
||||
|
||||
If you haven't already done so, the first step is to install Raspberry Pi OS onto an sdcard, and then boot up the Pi. Once booted, follow the below commands.
|
||||
|
||||
## Update System
|
||||
|
||||
```
|
||||
sudo apt update
|
||||
sudo apt upgrade
|
||||
```
|
||||
|
||||
## Install System Dependencies
|
||||
|
||||
```
|
||||
sudo apt install git
|
||||
sudo apt install python3-pip
|
||||
```
|
||||
|
||||
## Install NodeJS v22
|
||||
|
||||
```
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/nodesource.gpg
|
||||
NODE_MAJOR=22
|
||||
echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
|
||||
sudo apt update
|
||||
sudo apt install nodejs
|
||||
```
|
||||
|
||||
## Install pnpm
|
||||
|
||||
```
|
||||
corepack enable
|
||||
corepack prepare pnpm@latest --activate
|
||||
```
|
||||
|
||||
## Install MeshChat
|
||||
|
||||
```
|
||||
git clone https://github.com/liamcottle/reticulum-meshchat
|
||||
cd reticulum-meshchat
|
||||
pip install -r requirements.txt --break-system-packages
|
||||
pnpm install --prod
|
||||
pnpm run build-frontend
|
||||
```
|
||||
|
||||
## Run MeshChat
|
||||
|
||||
```
|
||||
python meshchat.py --headless --host 0.0.0.0
|
||||
```
|
||||
|
||||
## Configure Service
|
||||
|
||||
Adding a `systemd` service will allow MeshChat to run in the background when you disconnect from the Pi's terminal.
|
||||
|
||||
```
|
||||
sudo nano /etc/systemd/system/reticulum-meshchat.service
|
||||
```
|
||||
|
||||
```
|
||||
[Unit]
|
||||
Description=reticulum-meshchat
|
||||
After=network.target
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
RestartSec=1
|
||||
User=liamcottle
|
||||
Group=liamcottle
|
||||
WorkingDirectory=/home/liamcottle/reticulum-meshchat
|
||||
ExecStart=/usr/bin/env python /home/liamcottle/reticulum-meshchat/meshchat.py --headless --host 0.0.0.0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
> Note: Make sure to update the usernames in the service file if needed.
|
||||
|
||||
```
|
||||
sudo systemctl enable reticulum-meshchat.service
|
||||
sudo systemctl start reticulum-meshchat.service
|
||||
sudo systemctl status reticulum-meshchat.service
|
||||
```
|
||||
|
||||
You should now be able to access MeshChat via your Pi's IP address.
|
||||
|
||||
> Note: Don't forget to include the default port `8000`
|
||||
BIN
electron/assets/images/logo.png
Normal file
BIN
electron/assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
File diff suppressed because one or more lines are too long
BIN
electron/build/icon.png
Normal file
BIN
electron/build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
161
electron/loading.html
Normal file
161
electron/loading.html
Normal file
@@ -0,0 +1,161 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>MeshChatX</title>
|
||||
<script src="./assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 text-gray-900 antialiased dark:bg-zinc-950 dark:text-zinc-50 transition-colors">
|
||||
|
||||
<div class="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div class="absolute -left-32 -top-40 h-80 w-80 rounded-full bg-gradient-to-br from-blue-500/30 via-indigo-500/20 to-purple-500/30 blur-3xl dark:from-blue-600/25 dark:via-indigo-600/25 dark:to-purple-600/25"></div>
|
||||
<div class="absolute -right-24 top-20 h-64 w-64 rounded-full bg-gradient-to-br from-emerald-400/30 via-cyan-500/20 to-blue-500/30 blur-3xl dark:from-emerald-500/25 dark:via-cyan-500/25 dark:to-blue-500/25"></div>
|
||||
</div>
|
||||
|
||||
<main class="relative flex min-h-screen items-center justify-center px-6 py-10">
|
||||
<div class="w-full max-w-xl">
|
||||
<div class="rounded-3xl border border-slate-200/80 bg-white/80 shadow-2xl backdrop-blur-xl ring-1 ring-white/60 dark:border-zinc-800/70 dark:bg-zinc-900/70 dark:ring-zinc-800/70 transition-colors">
|
||||
<div class="p-8 space-y-6">
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-500 via-indigo-500 to-purple-500 shadow-lg ring-4 ring-white/60 dark:ring-zinc-800/70">
|
||||
<img class="h-10 w-10 object-contain" src="./assets/images/logo.png" alt="MeshChatX logo">
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-blue-600 dark:text-blue-300">MeshChatX</p>
|
||||
<div class="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">MeshChatX</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Custom fork by Sudo-Ivan</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-2xl border border-dashed border-slate-200/90 bg-slate-50/70 px-4 py-3 text-sm text-gray-700 dark:border-zinc-800/80 dark:bg-zinc-900/70 dark:text-gray-200 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
<span>Preparing your node</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-blue-100/80 px-3 py-1 text-xs font-semibold text-blue-700 shadow-sm dark:bg-blue-900/50 dark:text-blue-200">
|
||||
<span class="h-2 w-2 rounded-full bg-blue-500"></span>
|
||||
<span id="status-text">Starting services</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative inline-flex h-14 w-14 items-center justify-center">
|
||||
<span class="absolute inset-0 rounded-full border-4 border-blue-500/25 dark:border-blue-500/20"></span>
|
||||
<span class="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-blue-500 dark:border-t-blue-400"></span>
|
||||
<span class="absolute inset-2 rounded-full bg-blue-500/10 dark:bg-blue-500/15"></span>
|
||||
</div>
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="text-base font-medium text-gray-900 dark:text-white">Loading services</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Waiting for the MeshChatX API to come online.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="rounded-2xl border border-slate-200/90 bg-white/70 p-4 dark:border-zinc-800/80 dark:bg-zinc-900/70 transition-colors">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Version</div>
|
||||
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white" id="app-version">v0.0.0</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200/90 bg-white/70 p-4 text-right dark:border-zinc-800/80 dark:bg-zinc-900/70 transition-colors">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Status</div>
|
||||
<div class="mt-1 text-lg font-semibold text-emerald-600 dark:text-emerald-300" id="status-badge">Booting</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const statusText = document.getElementById("status-text");
|
||||
const statusBadge = document.getElementById("status-badge");
|
||||
|
||||
applyTheme(detectPreferredTheme());
|
||||
showAppVersion();
|
||||
check();
|
||||
listenForSystemThemeChanges();
|
||||
|
||||
async function showAppVersion() {
|
||||
const appVersion = await window.electron.appVersion();
|
||||
document.getElementById("app-version").innerText = "v" + appVersion;
|
||||
}
|
||||
|
||||
function detectPreferredTheme() {
|
||||
try {
|
||||
const storedTheme = localStorage.getItem("meshchat.theme") || localStorage.getItem("meshchatx.theme");
|
||||
if (storedTheme === "dark" || storedTheme === "light") {
|
||||
return storedTheme;
|
||||
}
|
||||
} catch (e) {}
|
||||
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const isDark = theme === "dark";
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
document.body.dataset.theme = isDark ? "dark" : "light";
|
||||
}
|
||||
|
||||
function listenForSystemThemeChanges() {
|
||||
if (!window.matchMedia) {
|
||||
return;
|
||||
}
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
media.addEventListener("change", (event) => {
|
||||
applyTheme(event.matches ? "dark" : "light");
|
||||
});
|
||||
}
|
||||
|
||||
let detectedProtocol = "http";
|
||||
|
||||
async function check() {
|
||||
const protocols = ["https", "http"];
|
||||
for (const protocol of protocols) {
|
||||
try {
|
||||
const result = await fetch(`${protocol}://localhost:9337/api/v1/status`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
const status = result.status;
|
||||
const data = await result.json();
|
||||
if (status === 200 && data.status === "ok") {
|
||||
detectedProtocol = protocol;
|
||||
statusText.innerText = "Launching UI";
|
||||
statusBadge.innerText = "Ready";
|
||||
syncThemeFromConfig();
|
||||
setTimeout(onReady, 200);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
setTimeout(check, 300);
|
||||
}
|
||||
|
||||
function onReady() {
|
||||
const timestamp = (new Date()).getTime();
|
||||
window.location.href = `${detectedProtocol}://localhost:9337/?nocache=${timestamp}`;
|
||||
}
|
||||
|
||||
async function syncThemeFromConfig() {
|
||||
try {
|
||||
const response = await fetch(`${detectedProtocol}://localhost:9337/api/v1/config`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const config = await response.json();
|
||||
if (config && (config.theme === "dark" || config.theme === "light")) {
|
||||
applyTheme(config.theme);
|
||||
try {
|
||||
localStorage.setItem("meshchat.theme", config.theme);
|
||||
} catch (e) {}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
346
electron/main.js
Normal file
346
electron/main.js
Normal file
@@ -0,0 +1,346 @@
|
||||
const { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } = require("electron");
|
||||
const electronPrompt = require("electron-prompt");
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// remember main window
|
||||
var mainWindow = null;
|
||||
|
||||
// remember child process for exe so we can kill it when app exits
|
||||
var exeChildProcess = null;
|
||||
|
||||
// allow fetching app version via ipc
|
||||
ipcMain.handle("app-version", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
// add support for showing an alert window via ipc
|
||||
ipcMain.handle("alert", async (event, message) => {
|
||||
return await dialog.showMessageBox(mainWindow, {
|
||||
message: message,
|
||||
});
|
||||
});
|
||||
|
||||
// add support for showing a confirm window via ipc
|
||||
ipcMain.handle("confirm", async (event, message) => {
|
||||
// show confirm dialog
|
||||
const result = await dialog.showMessageBox(mainWindow, {
|
||||
type: "question",
|
||||
title: "Confirm",
|
||||
message: message,
|
||||
cancelId: 0, // esc key should press cancel button
|
||||
defaultId: 1, // enter key should press ok button
|
||||
buttons: [
|
||||
"Cancel", // 0
|
||||
"OK", // 1
|
||||
],
|
||||
});
|
||||
|
||||
// check if user clicked OK
|
||||
return result.response === 1;
|
||||
});
|
||||
|
||||
// add support for showing a prompt window via ipc
|
||||
ipcMain.handle("prompt", async (event, message) => {
|
||||
return await electronPrompt({
|
||||
title: message,
|
||||
label: "",
|
||||
value: "",
|
||||
type: "input",
|
||||
inputAttrs: {
|
||||
type: "text",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// allow relaunching app via ipc
|
||||
ipcMain.handle("relaunch", () => {
|
||||
app.relaunch();
|
||||
app.exit();
|
||||
});
|
||||
|
||||
// allow showing a file path in os file manager
|
||||
ipcMain.handle("showPathInFolder", (event, path) => {
|
||||
shell.showItemInFolder(path);
|
||||
});
|
||||
|
||||
function log(message) {
|
||||
// log to stdout of this process
|
||||
console.log(message);
|
||||
|
||||
// make sure main window exists
|
||||
if (!mainWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure window is not destroyed
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// log to web console
|
||||
mainWindow.webContents.send("log", message);
|
||||
}
|
||||
|
||||
function getDefaultStorageDir() {
|
||||
// if we are running a windows portable exe, we want to use .reticulum-meshchat in the portable exe dir
|
||||
// e.g if we launch "E:\Some\Path\MeshChat.exe" we want to use "E:\Some\Path\.reticulum-meshchat"
|
||||
const portableExecutableDir = process.env.PORTABLE_EXECUTABLE_DIR;
|
||||
if (process.platform === "win32" && portableExecutableDir != null) {
|
||||
return path.join(portableExecutableDir, ".reticulum-meshchat");
|
||||
}
|
||||
|
||||
// otherwise, we will fall back to putting the storage dir in the users home directory
|
||||
// e.g: ~/.reticulum-meshchat
|
||||
return path.join(app.getPath("home"), ".reticulum-meshchat");
|
||||
}
|
||||
|
||||
function getDefaultReticulumConfigDir() {
|
||||
// if we are running a windows portable exe, we want to use .reticulum in the portable exe dir
|
||||
// e.g if we launch "E:\Some\Path\MeshChat.exe" we want to use "E:\Some\Path\.reticulum"
|
||||
const portableExecutableDir = process.env.PORTABLE_EXECUTABLE_DIR;
|
||||
if (process.platform === "win32" && portableExecutableDir != null) {
|
||||
return path.join(portableExecutableDir, ".reticulum");
|
||||
}
|
||||
|
||||
// otherwise, we will fall back to using the .reticulum folder in the users home directory
|
||||
// e.g: ~/.reticulum
|
||||
return path.join(app.getPath("home"), ".reticulum");
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// get arguments passed to application, and remove the provided application path
|
||||
const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"];
|
||||
const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg));
|
||||
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
|
||||
|
||||
if (!shouldLaunchHeadless) {
|
||||
// create browser window
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1500,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
// used to inject logging over ipc
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
// Security: disable node integration in renderer
|
||||
nodeIntegration: false,
|
||||
// Security: enable context isolation (default in Electron 12+)
|
||||
contextIsolation: true,
|
||||
// Security: enable sandbox for additional protection
|
||||
sandbox: true,
|
||||
// Security: disable remote module (deprecated but explicit)
|
||||
enableRemoteModule: false,
|
||||
},
|
||||
});
|
||||
|
||||
// open external links in default web browser instead of electron
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
var shouldShowInNewElectronWindow = false;
|
||||
|
||||
// we want to open call.html in a new electron window
|
||||
// but all other target="_blank" links should open in the system web browser
|
||||
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
|
||||
if (
|
||||
(url.startsWith("http://localhost") || url.startsWith("https://localhost")) &&
|
||||
url.includes("/call.html")
|
||||
) {
|
||||
shouldShowInNewElectronWindow = true;
|
||||
}
|
||||
|
||||
// we want to open blob urls in a new electron window
|
||||
else if (url.startsWith("blob:")) {
|
||||
shouldShowInNewElectronWindow = true;
|
||||
}
|
||||
|
||||
// open in new electron window
|
||||
if (shouldShowInNewElectronWindow) {
|
||||
return {
|
||||
action: "allow",
|
||||
};
|
||||
}
|
||||
|
||||
// fallback to opening any other url in external browser
|
||||
shell.openExternal(url);
|
||||
return {
|
||||
action: "deny",
|
||||
};
|
||||
});
|
||||
|
||||
// navigate to loading page
|
||||
await mainWindow.loadFile(path.join(__dirname, "loading.html"));
|
||||
|
||||
// ask mac users for microphone access for audio calls to work
|
||||
if (process.platform === "darwin") {
|
||||
await systemPreferences.askForMediaAccess("microphone");
|
||||
}
|
||||
}
|
||||
|
||||
// find path to python/cxfreeze reticulum meshchat executable
|
||||
// Note: setup.py creates ReticulumMeshChatX (with X), not ReticulumMeshChat
|
||||
const exeName = process.platform === "win32" ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX";
|
||||
|
||||
// get app path (handles both development and packaged app)
|
||||
const appPath = app.getAppPath();
|
||||
// get resources path (where extraFiles are placed)
|
||||
const resourcesPath = process.resourcesPath || path.join(appPath, "..", "..");
|
||||
var exe = null;
|
||||
|
||||
// when packaged, extraFiles are placed at resources/app/electron/build/exe
|
||||
// when packaged with asar, unpacked files are in app.asar.unpacked/ directory
|
||||
// app.getAppPath() returns the path to app.asar, so unpacked is at the same level
|
||||
const possiblePaths = [
|
||||
// packaged app - extraFiles location (resources/app/electron/build/exe)
|
||||
path.join(resourcesPath, "app", "electron", "build", "exe", exeName),
|
||||
// packaged app with asar (unpacked files from asarUnpack)
|
||||
path.join(appPath, "..", "app.asar.unpacked", "build", "exe", exeName),
|
||||
// packaged app without asar (relative to app path)
|
||||
path.join(appPath, "build", "exe", exeName),
|
||||
// development mode (relative to electron directory)
|
||||
path.join(__dirname, "build", "exe", exeName),
|
||||
// development mode (relative to project root)
|
||||
path.join(__dirname, "..", "build", "exe", exeName),
|
||||
];
|
||||
|
||||
// find the first path that exists
|
||||
for (const possibleExe of possiblePaths) {
|
||||
if (fs.existsSync(possibleExe)) {
|
||||
exe = possibleExe;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// verify executable exists
|
||||
if (!exe || !fs.existsSync(exe)) {
|
||||
const errorMsg = `Could not find executable: ${exeName}\nChecked paths:\n${possiblePaths.join("\n")}\n\nApp path: ${appPath}\nResources path: ${resourcesPath}`;
|
||||
log(errorMsg);
|
||||
if (mainWindow) {
|
||||
await dialog.showMessageBox(mainWindow, {
|
||||
message: errorMsg,
|
||||
});
|
||||
}
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Found executable at: ${exe}`);
|
||||
|
||||
try {
|
||||
// arguments we always want to pass in
|
||||
const requiredArguments = [
|
||||
"--headless", // reticulum meshchat usually launches default web browser, we don't want this when using electron
|
||||
"--port",
|
||||
"9337", // FIXME: let system pick a random unused port?
|
||||
// '--test-exception-message', 'Test Exception Message', // uncomment to test the crash dialog
|
||||
];
|
||||
|
||||
// if user didn't provide reticulum config dir, we should provide it
|
||||
if (!userProvidedArguments.includes("--reticulum-config-dir")) {
|
||||
requiredArguments.push("--reticulum-config-dir", getDefaultReticulumConfigDir());
|
||||
}
|
||||
|
||||
// if user didn't provide storage dir, we should provide it
|
||||
if (!userProvidedArguments.includes("--storage-dir")) {
|
||||
requiredArguments.push("--storage-dir", getDefaultStorageDir());
|
||||
}
|
||||
|
||||
// spawn executable
|
||||
exeChildProcess = await spawn(exe, [
|
||||
...requiredArguments, // always provide required arguments
|
||||
...userProvidedArguments, // also include any user provided arguments
|
||||
]);
|
||||
|
||||
// log stdout
|
||||
var stdoutLines = [];
|
||||
exeChildProcess.stdout.setEncoding("utf8");
|
||||
exeChildProcess.stdout.on("data", function (data) {
|
||||
// log
|
||||
log(data.toString());
|
||||
|
||||
// keep track of last 10 stdout lines
|
||||
stdoutLines.push(data.toString());
|
||||
if (stdoutLines.length > 10) {
|
||||
stdoutLines.shift();
|
||||
}
|
||||
});
|
||||
|
||||
// log stderr
|
||||
var stderrLines = [];
|
||||
exeChildProcess.stderr.setEncoding("utf8");
|
||||
exeChildProcess.stderr.on("data", function (data) {
|
||||
// log
|
||||
log(data.toString());
|
||||
|
||||
// keep track of last 10 stderr lines
|
||||
stderrLines.push(data.toString());
|
||||
if (stderrLines.length > 10) {
|
||||
stderrLines.shift();
|
||||
}
|
||||
});
|
||||
|
||||
// log errors
|
||||
exeChildProcess.on("error", function (error) {
|
||||
log(error);
|
||||
});
|
||||
|
||||
// quit electron app if exe dies
|
||||
exeChildProcess.on("exit", async function (code) {
|
||||
// if no exit code provided, we wanted exit to happen, so do nothing
|
||||
if (code == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// tell user that Visual C++ redistributable needs to be installed on Windows
|
||||
if (code === 3221225781 && process.platform === "win32") {
|
||||
await dialog.showMessageBox(mainWindow, {
|
||||
message: "Microsoft Visual C++ redistributable must be installed to run this application.",
|
||||
});
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
// show crash log
|
||||
const stdout = stdoutLines.join("");
|
||||
const stderr = stderrLines.join("");
|
||||
await dialog.showMessageBox(mainWindow, {
|
||||
message: [
|
||||
"MeshChat Crashed!",
|
||||
"",
|
||||
`Exit Code: ${code}`,
|
||||
"",
|
||||
`----- stdout -----`,
|
||||
"",
|
||||
stdout,
|
||||
`----- stderr -----`,
|
||||
"",
|
||||
stderr,
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
// quit after dismissing error dialog
|
||||
app.quit();
|
||||
});
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
});
|
||||
|
||||
function quit() {
|
||||
// kill python process
|
||||
if (exeChildProcess) {
|
||||
exeChildProcess.kill("SIGKILL");
|
||||
}
|
||||
|
||||
// quit electron app
|
||||
app.quit();
|
||||
}
|
||||
|
||||
// quit electron if all windows are closed
|
||||
app.on("window-all-closed", () => {
|
||||
quit();
|
||||
});
|
||||
|
||||
// make sure child process is killed if app is quiting
|
||||
app.on("quit", () => {
|
||||
quit();
|
||||
});
|
||||
36
electron/preload.js
Normal file
36
electron/preload.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const { ipcRenderer, contextBridge } = require("electron");
|
||||
|
||||
// forward logs received from exe to web console
|
||||
ipcRenderer.on("log", (event, message) => console.log(message));
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
// allow fetching app version in electron browser window
|
||||
appVersion: async function () {
|
||||
return await ipcRenderer.invoke("app-version");
|
||||
},
|
||||
|
||||
// show an alert dialog in electron browser window, this fixes a bug where alert breaks input fields on windows
|
||||
alert: async function (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
|
||||
prompt: async function (message) {
|
||||
return await ipcRenderer.invoke("prompt", message);
|
||||
},
|
||||
|
||||
// allow relaunching app in electron browser window
|
||||
relaunch: async function () {
|
||||
return await ipcRenderer.invoke("relaunch");
|
||||
},
|
||||
|
||||
// allow showing a file path in os file manager
|
||||
showPathInFolder: async function (path) {
|
||||
return await ipcRenderer.invoke("showPathInFolder", path);
|
||||
},
|
||||
});
|
||||
53
eslint.config.mjs
Normal file
53
eslint.config.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import js from "@eslint/js";
|
||||
import pluginVue from "eslint-plugin-vue";
|
||||
import pluginPrettier from "eslint-plugin-prettier/recommended";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/electron/assets/**",
|
||||
"**/meshchatx/public/**",
|
||||
"**/meshchatx/src/frontend/public/**",
|
||||
"**/storage/**",
|
||||
"**/__pycache__/**",
|
||||
"**/.venv/**",
|
||||
"**/*.min.js",
|
||||
"**/pnpm-lock.yaml",
|
||||
"**/poetry.lock",
|
||||
"**/linux-unpacked/**",
|
||||
"**/win-unpacked/**",
|
||||
"**/mac-unpacked/**",
|
||||
"**/*.asar",
|
||||
"**/*.asar.unpacked/**",
|
||||
"**/*.wasm",
|
||||
"**/*.proto",
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,vue}"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
axios: "readonly",
|
||||
Codec2Lib: "readonly",
|
||||
Codec2MicrophoneRecorder: "readonly",
|
||||
},
|
||||
},
|
||||
},
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs["flat/recommended"],
|
||||
pluginPrettier,
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,vue}"],
|
||||
rules: {
|
||||
"vue/multi-word-component-names": "off",
|
||||
"no-unused-vars": "warn",
|
||||
"no-console": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
BIN
logo/icon.ico
Normal file
BIN
logo/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
BIN
logo/logo-chat-bubble.png
Normal file
BIN
logo/logo-chat-bubble.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
BIN
logo/logo.afdesign
Normal file
BIN
logo/logo.afdesign
Normal file
Binary file not shown.
BIN
logo/logo.png
Normal file
BIN
logo/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
3
meshchatx/__init__.py
Normal file
3
meshchatx/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Reticulum MeshChatX - A mesh network communications app."""
|
||||
|
||||
__version__ = "2.50.0"
|
||||
7025
meshchatx/meshchat.py
Normal file
7025
meshchatx/meshchat.py
Normal file
File diff suppressed because it is too large
Load Diff
29
meshchatx/src/__init__.py
Normal file
29
meshchatx/src/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import sys
|
||||
|
||||
# NOTE: this class is required to be able to use print/log commands and have them flush to stdout and stderr immediately
|
||||
# without wrapper stdout and stderr, when using `childProcess.stdout.on('data', ...)` in NodeJS script, we never get
|
||||
# any events fired until the process exits. However, force flushing the streams does fire the callbacks in NodeJS.
|
||||
|
||||
|
||||
# this class forces stream writes to be flushed immediately
|
||||
class ImmediateFlushingStreamWrapper:
|
||||
def __init__(self, stream):
|
||||
self.stream = stream
|
||||
|
||||
# force write to flush immediately
|
||||
def write(self, data):
|
||||
self.stream.write(data)
|
||||
self.stream.flush()
|
||||
|
||||
# force writelines to flush immediately
|
||||
def writelines(self, lines):
|
||||
self.stream.writelines(lines)
|
||||
self.stream.flush()
|
||||
|
||||
def __getattr__(self, attr):
|
||||
return getattr(self.stream, attr)
|
||||
|
||||
|
||||
# wrap stdout and stderr with our custom wrapper
|
||||
sys.stdout = ImmediateFlushingStreamWrapper(sys.stdout)
|
||||
sys.stderr = ImmediateFlushingStreamWrapper(sys.stderr)
|
||||
1
meshchatx/src/backend/__init__.py
Normal file
1
meshchatx/src/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend utilities shared by the Reticulum MeshChatX CLI."""
|
||||
27
meshchatx/src/backend/announce_handler.py
Normal file
27
meshchatx/src/backend/announce_handler.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# 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
|
||||
class AnnounceHandler:
|
||||
def __init__(self, aspect_filter: str, received_announce_callback):
|
||||
self.aspect_filter = aspect_filter
|
||||
self.received_announce_callback = received_announce_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,
|
||||
):
|
||||
try:
|
||||
# handle received announce
|
||||
self.received_announce_callback(
|
||||
self.aspect_filter,
|
||||
destination_hash,
|
||||
announced_identity,
|
||||
app_data,
|
||||
announce_packet_hash,
|
||||
)
|
||||
except Exception as e:
|
||||
# ignore failure to handle received announce
|
||||
print(f"Failed to handle received announce: {e}")
|
||||
59
meshchatx/src/backend/announce_manager.py
Normal file
59
meshchatx/src/backend/announce_manager.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import base64
|
||||
|
||||
from .database import Database
|
||||
|
||||
|
||||
class AnnounceManager:
|
||||
def __init__(self, db: Database):
|
||||
self.db = db
|
||||
|
||||
def upsert_announce(self, reticulum, identity, destination_hash, aspect, app_data, announce_packet_hash):
|
||||
# get rssi, snr and signal quality if available
|
||||
rssi = reticulum.get_packet_rssi(announce_packet_hash)
|
||||
snr = reticulum.get_packet_snr(announce_packet_hash)
|
||||
quality = reticulum.get_packet_q(announce_packet_hash)
|
||||
|
||||
# prepare data to insert or update
|
||||
data = {
|
||||
"destination_hash": destination_hash.hex() if isinstance(destination_hash, bytes) else destination_hash,
|
||||
"aspect": aspect,
|
||||
"identity_hash": identity.hash.hex(),
|
||||
"identity_public_key": base64.b64encode(identity.get_public_key()).decode(
|
||||
"utf-8",
|
||||
),
|
||||
"rssi": rssi,
|
||||
"snr": snr,
|
||||
"quality": quality,
|
||||
}
|
||||
|
||||
# only set app data if provided
|
||||
if app_data is not None:
|
||||
data["app_data"] = base64.b64encode(app_data).decode("utf-8")
|
||||
|
||||
self.db.announces.upsert_announce(data)
|
||||
|
||||
def get_filtered_announces(self, aspect=None, identity_hash=None, destination_hash=None, query=None, blocked_identity_hashes=None):
|
||||
sql = "SELECT * FROM announces WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if aspect:
|
||||
sql += " AND aspect = ?"
|
||||
params.append(aspect)
|
||||
if identity_hash:
|
||||
sql += " AND identity_hash = ?"
|
||||
params.append(identity_hash)
|
||||
if destination_hash:
|
||||
sql += " AND destination_hash = ?"
|
||||
params.append(destination_hash)
|
||||
if query:
|
||||
like_term = f"%{query}%"
|
||||
sql += " AND (destination_hash LIKE ? OR identity_hash LIKE ?)"
|
||||
params.extend([like_term, like_term])
|
||||
if blocked_identity_hashes:
|
||||
placeholders = ", ".join(["?"] * len(blocked_identity_hashes))
|
||||
sql += f" AND identity_hash NOT IN ({placeholders})"
|
||||
params.extend(blocked_identity_hashes)
|
||||
|
||||
sql += " ORDER BY updated_at DESC"
|
||||
return self.db.provider.fetchall(sql, params)
|
||||
|
||||
44
meshchatx/src/backend/archiver_manager.py
Normal file
44
meshchatx/src/backend/archiver_manager.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import hashlib
|
||||
|
||||
from .database import Database
|
||||
|
||||
|
||||
class ArchiverManager:
|
||||
def __init__(self, db: Database):
|
||||
self.db = db
|
||||
|
||||
def archive_page(self, destination_hash, page_path, content, max_versions=5, max_storage_gb=1):
|
||||
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
# Check if already exists
|
||||
existing = self.db.provider.fetchone(
|
||||
"SELECT id FROM archived_pages WHERE destination_hash = ? AND page_path = ? AND hash = ?",
|
||||
(destination_hash, page_path, content_hash),
|
||||
)
|
||||
if existing:
|
||||
return
|
||||
|
||||
# Insert new version
|
||||
self.db.misc.archive_page(destination_hash, page_path, content, content_hash)
|
||||
|
||||
# Enforce max versions per page
|
||||
versions = self.db.misc.get_archived_page_versions(destination_hash, page_path)
|
||||
if len(versions) > max_versions:
|
||||
# Delete older versions
|
||||
to_delete = versions[max_versions:]
|
||||
for version in to_delete:
|
||||
self.db.provider.execute("DELETE FROM archived_pages WHERE id = ?", (version["id"],))
|
||||
|
||||
# Enforce total storage limit (approximate)
|
||||
total_size_row = self.db.provider.fetchone("SELECT SUM(LENGTH(content)) as total_size FROM archived_pages")
|
||||
total_size = total_size_row["total_size"] or 0
|
||||
max_bytes = max_storage_gb * 1024 * 1024 * 1024
|
||||
|
||||
while total_size > max_bytes:
|
||||
oldest = self.db.provider.fetchone("SELECT id, LENGTH(content) as size FROM archived_pages ORDER BY created_at ASC LIMIT 1")
|
||||
if oldest:
|
||||
self.db.provider.execute("DELETE FROM archived_pages WHERE id = ?", (oldest["id"],))
|
||||
total_size -= oldest["size"]
|
||||
else:
|
||||
break
|
||||
|
||||
23
meshchatx/src/backend/async_utils.py
Normal file
23
meshchatx/src/backend/async_utils.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import asyncio
|
||||
from collections.abc import Coroutine
|
||||
|
||||
|
||||
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
|
||||
# it will run the async function on the main event loop if possible, otherwise it logs a warning
|
||||
@staticmethod
|
||||
def run_async(coroutine: Coroutine):
|
||||
# run provided coroutine on main event loop, ensuring thread safety
|
||||
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
|
||||
return
|
||||
|
||||
# main event loop not running...
|
||||
print("WARNING: Main event loop not available. Could not schedule task.")
|
||||
8
meshchatx/src/backend/colour_utils.py
Normal file
8
meshchatx/src/backend/colour_utils.py
Normal file
@@ -0,0 +1,8 @@
|
||||
class ColourUtils:
|
||||
@staticmethod
|
||||
def hex_colour_to_byte_array(hex_colour):
|
||||
# remove leading "#"
|
||||
hex_colour = hex_colour.lstrip("#")
|
||||
|
||||
# convert the remaining hex string to bytes
|
||||
return bytes.fromhex(hex_colour)
|
||||
131
meshchatx/src/backend/config_manager.py
Normal file
131
meshchatx/src/backend/config_manager.py
Normal file
@@ -0,0 +1,131 @@
|
||||
|
||||
class ConfigManager:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
# all possible config items
|
||||
self.database_version = self.IntConfig(self, "database_version", None)
|
||||
self.display_name = self.StringConfig(self, "display_name", "Anonymous Peer")
|
||||
self.auto_announce_enabled = self.BoolConfig(self, "auto_announce_enabled", False)
|
||||
self.auto_announce_interval_seconds = self.IntConfig(self, "auto_announce_interval_seconds", 0)
|
||||
self.last_announced_at = self.IntConfig(self, "last_announced_at", None)
|
||||
self.theme = self.StringConfig(self, "theme", "light")
|
||||
self.language = self.StringConfig(self, "language", "en")
|
||||
self.auto_resend_failed_messages_when_announce_received = self.BoolConfig(
|
||||
self, "auto_resend_failed_messages_when_announce_received", True,
|
||||
)
|
||||
self.allow_auto_resending_failed_messages_with_attachments = self.BoolConfig(
|
||||
self, "allow_auto_resending_failed_messages_with_attachments", False,
|
||||
)
|
||||
self.auto_send_failed_messages_to_propagation_node = self.BoolConfig(
|
||||
self, "auto_send_failed_messages_to_propagation_node", False,
|
||||
)
|
||||
self.show_suggested_community_interfaces = self.BoolConfig(
|
||||
self, "show_suggested_community_interfaces", True,
|
||||
)
|
||||
self.lxmf_delivery_transfer_limit_in_bytes = self.IntConfig(
|
||||
self, "lxmf_delivery_transfer_limit_in_bytes", 1000 * 1000 * 10,
|
||||
) # 10MB
|
||||
self.lxmf_preferred_propagation_node_destination_hash = self.StringConfig(
|
||||
self, "lxmf_preferred_propagation_node_destination_hash", None,
|
||||
)
|
||||
self.lxmf_preferred_propagation_node_auto_sync_interval_seconds = self.IntConfig(
|
||||
self, "lxmf_preferred_propagation_node_auto_sync_interval_seconds", 0,
|
||||
)
|
||||
self.lxmf_preferred_propagation_node_last_synced_at = self.IntConfig(
|
||||
self, "lxmf_preferred_propagation_node_last_synced_at", None,
|
||||
)
|
||||
self.lxmf_local_propagation_node_enabled = self.BoolConfig(
|
||||
self, "lxmf_local_propagation_node_enabled", False,
|
||||
)
|
||||
self.lxmf_user_icon_name = self.StringConfig(self, "lxmf_user_icon_name", None)
|
||||
self.lxmf_user_icon_foreground_colour = self.StringConfig(
|
||||
self, "lxmf_user_icon_foreground_colour", None,
|
||||
)
|
||||
self.lxmf_user_icon_background_colour = self.StringConfig(
|
||||
self, "lxmf_user_icon_background_colour", None,
|
||||
)
|
||||
self.lxmf_inbound_stamp_cost = self.IntConfig(
|
||||
self, "lxmf_inbound_stamp_cost", 8,
|
||||
) # for direct delivery messages
|
||||
self.lxmf_propagation_node_stamp_cost = self.IntConfig(
|
||||
self, "lxmf_propagation_node_stamp_cost", 16,
|
||||
) # for propagation node messages
|
||||
self.page_archiver_enabled = self.BoolConfig(self, "page_archiver_enabled", True)
|
||||
self.page_archiver_max_versions = self.IntConfig(self, "page_archiver_max_versions", 5)
|
||||
self.archives_max_storage_gb = self.IntConfig(self, "archives_max_storage_gb", 1)
|
||||
self.crawler_enabled = self.BoolConfig(self, "crawler_enabled", False)
|
||||
self.crawler_max_retries = self.IntConfig(self, "crawler_max_retries", 3)
|
||||
self.crawler_retry_delay_seconds = self.IntConfig(self, "crawler_retry_delay_seconds", 3600)
|
||||
self.crawler_max_concurrent = self.IntConfig(self, "crawler_max_concurrent", 1)
|
||||
self.auth_enabled = self.BoolConfig(self, "auth_enabled", False)
|
||||
self.auth_password_hash = self.StringConfig(self, "auth_password_hash", None)
|
||||
self.auth_session_secret = self.StringConfig(self, "auth_session_secret", None)
|
||||
|
||||
# map config
|
||||
self.map_offline_enabled = self.BoolConfig(self, "map_offline_enabled", False)
|
||||
self.map_offline_path = self.StringConfig(self, "map_offline_path", None)
|
||||
self.map_mbtiles_dir = self.StringConfig(self, "map_mbtiles_dir", None)
|
||||
self.map_tile_cache_enabled = self.BoolConfig(self, "map_tile_cache_enabled", True)
|
||||
self.map_default_lat = self.StringConfig(self, "map_default_lat", "0.0")
|
||||
self.map_default_lon = self.StringConfig(self, "map_default_lon", "0.0")
|
||||
self.map_default_zoom = self.IntConfig(self, "map_default_zoom", 2)
|
||||
self.map_tile_server_url = self.StringConfig(
|
||||
self, "map_tile_server_url", "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
)
|
||||
self.map_nominatim_api_url = self.StringConfig(
|
||||
self, "map_nominatim_api_url", "https://nominatim.openstreetmap.org",
|
||||
)
|
||||
|
||||
def get(self, key: str, default_value=None) -> str | None:
|
||||
return self.db.config.get(key, default_value)
|
||||
|
||||
def set(self, key: str, value: str | None):
|
||||
self.db.config.set(key, value)
|
||||
|
||||
class StringConfig:
|
||||
def __init__(self, manager, key: str, default_value: str | None = None):
|
||||
self.manager = manager
|
||||
self.key = key
|
||||
self.default_value = default_value
|
||||
|
||||
def get(self, default_value: str = None) -> str | None:
|
||||
_default_value = default_value or self.default_value
|
||||
return self.manager.get(self.key, default_value=_default_value)
|
||||
|
||||
def set(self, value: str | None):
|
||||
self.manager.set(self.key, value)
|
||||
|
||||
class BoolConfig:
|
||||
def __init__(self, manager, key: str, default_value: bool = False):
|
||||
self.manager = manager
|
||||
self.key = key
|
||||
self.default_value = default_value
|
||||
|
||||
def get(self) -> bool:
|
||||
config_value = self.manager.get(self.key, default_value=None)
|
||||
if config_value is None:
|
||||
return self.default_value
|
||||
return config_value == "true"
|
||||
|
||||
def set(self, value: bool):
|
||||
self.manager.set(self.key, "true" if value else "false")
|
||||
|
||||
class IntConfig:
|
||||
def __init__(self, manager, key: str, default_value: int | None = 0):
|
||||
self.manager = manager
|
||||
self.key = key
|
||||
self.default_value = default_value
|
||||
|
||||
def get(self) -> int | None:
|
||||
config_value = self.manager.get(self.key, default_value=None)
|
||||
if config_value is None:
|
||||
return self.default_value
|
||||
try:
|
||||
return int(config_value)
|
||||
except (ValueError, TypeError):
|
||||
return self.default_value
|
||||
|
||||
def set(self, value: int):
|
||||
self.manager.set(self.key, str(value))
|
||||
|
||||
35
meshchatx/src/backend/database/__init__.py
Normal file
35
meshchatx/src/backend/database/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from .announces import AnnounceDAO
|
||||
from .config import ConfigDAO
|
||||
from .legacy_migrator import LegacyMigrator
|
||||
from .messages import MessageDAO
|
||||
from .misc import MiscDAO
|
||||
from .provider import DatabaseProvider
|
||||
from .schema import DatabaseSchema
|
||||
from .telephone import TelephoneDAO
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, db_path):
|
||||
self.provider = DatabaseProvider.get_instance(db_path)
|
||||
self.schema = DatabaseSchema(self.provider)
|
||||
self.config = ConfigDAO(self.provider)
|
||||
self.messages = MessageDAO(self.provider)
|
||||
self.announces = AnnounceDAO(self.provider)
|
||||
self.misc = MiscDAO(self.provider)
|
||||
self.telephone = TelephoneDAO(self.provider)
|
||||
|
||||
def initialize(self):
|
||||
self.schema.initialize()
|
||||
|
||||
def migrate_from_legacy(self, reticulum_config_dir, identity_hash_hex):
|
||||
migrator = LegacyMigrator(self.provider, reticulum_config_dir, identity_hash_hex)
|
||||
if migrator.should_migrate():
|
||||
return migrator.migrate()
|
||||
return False
|
||||
|
||||
def execute_sql(self, query, params=None):
|
||||
return self.provider.execute(query, params)
|
||||
|
||||
def close(self):
|
||||
self.provider.close()
|
||||
|
||||
90
meshchatx/src/backend/database/announces.py
Normal file
90
meshchatx/src/backend/database/announces.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from .provider import DatabaseProvider
|
||||
|
||||
|
||||
class AnnounceDAO:
|
||||
def __init__(self, provider: DatabaseProvider):
|
||||
self.provider = provider
|
||||
|
||||
def upsert_announce(self, data):
|
||||
# Ensure data is a dict if it's a sqlite3.Row
|
||||
if not isinstance(data, dict):
|
||||
data = dict(data)
|
||||
|
||||
fields = [
|
||||
"destination_hash", "aspect", "identity_hash", "identity_public_key",
|
||||
"app_data", "rssi", "snr", "quality",
|
||||
]
|
||||
# These are safe as they are from a hardcoded list
|
||||
columns = ", ".join(fields)
|
||||
placeholders = ", ".join(["?"] * len(fields))
|
||||
update_set = ", ".join([f"{f} = EXCLUDED.{f}" for f in fields if f != "destination_hash"])
|
||||
|
||||
query = f"INSERT INTO announces ({columns}, updated_at) VALUES ({placeholders}, ?) " \
|
||||
f"ON CONFLICT(destination_hash) DO UPDATE SET {update_set}, updated_at = EXCLUDED.updated_at" # noqa: S608
|
||||
|
||||
params = [data.get(f) for f in fields]
|
||||
params.append(datetime.now(UTC))
|
||||
self.provider.execute(query, params)
|
||||
|
||||
def get_announces(self, aspect=None):
|
||||
if aspect:
|
||||
return self.provider.fetchall("SELECT * FROM announces WHERE aspect = ?", (aspect,))
|
||||
return self.provider.fetchall("SELECT * FROM announces")
|
||||
|
||||
def get_announce_by_hash(self, destination_hash):
|
||||
return self.provider.fetchone("SELECT * FROM announces WHERE destination_hash = ?", (destination_hash,))
|
||||
|
||||
def get_filtered_announces(self, aspect=None, search_term=None, limit=None, offset=0):
|
||||
query = "SELECT * FROM announces WHERE 1=1"
|
||||
params = []
|
||||
if aspect:
|
||||
query += " AND aspect = ?"
|
||||
params.append(aspect)
|
||||
if search_term:
|
||||
query += " AND (destination_hash LIKE ? OR identity_hash LIKE ?)"
|
||||
like_term = f"%{search_term}%"
|
||||
params.extend([like_term, like_term])
|
||||
|
||||
query += " ORDER BY updated_at DESC"
|
||||
|
||||
if limit:
|
||||
query += " LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
return self.provider.fetchall(query, params)
|
||||
|
||||
# Custom Display Names
|
||||
def upsert_custom_display_name(self, destination_hash, display_name):
|
||||
now = datetime.now(UTC)
|
||||
self.provider.execute("""
|
||||
INSERT INTO custom_destination_display_names (destination_hash, display_name, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, updated_at = EXCLUDED.updated_at
|
||||
""", (destination_hash, display_name, now))
|
||||
|
||||
def get_custom_display_name(self, destination_hash):
|
||||
row = self.provider.fetchone("SELECT display_name FROM custom_destination_display_names WHERE destination_hash = ?", (destination_hash,))
|
||||
return row["display_name"] if row else None
|
||||
|
||||
def delete_custom_display_name(self, destination_hash):
|
||||
self.provider.execute("DELETE FROM custom_destination_display_names WHERE destination_hash = ?", (destination_hash,))
|
||||
|
||||
# Favourites
|
||||
def upsert_favourite(self, destination_hash, display_name, aspect):
|
||||
now = datetime.now(UTC)
|
||||
self.provider.execute("""
|
||||
INSERT INTO favourite_destinations (destination_hash, display_name, aspect, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, aspect = EXCLUDED.aspect, updated_at = EXCLUDED.updated_at
|
||||
""", (destination_hash, display_name, aspect, now))
|
||||
|
||||
def get_favourites(self, aspect=None):
|
||||
if aspect:
|
||||
return self.provider.fetchall("SELECT * FROM favourite_destinations WHERE aspect = ?", (aspect,))
|
||||
return self.provider.fetchall("SELECT * FROM favourite_destinations")
|
||||
|
||||
def delete_favourite(self, destination_hash):
|
||||
self.provider.execute("DELETE FROM favourite_destinations WHERE destination_hash = ?", (destination_hash,))
|
||||
|
||||
27
meshchatx/src/backend/database/config.py
Normal file
27
meshchatx/src/backend/database/config.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from .provider import DatabaseProvider
|
||||
|
||||
|
||||
class ConfigDAO:
|
||||
def __init__(self, provider: DatabaseProvider):
|
||||
self.provider = provider
|
||||
|
||||
def get(self, key, default=None):
|
||||
row = self.provider.fetchone("SELECT value FROM config WHERE key = ?", (key,))
|
||||
if row:
|
||||
return row["value"]
|
||||
return default
|
||||
|
||||
def set(self, key, value):
|
||||
if value is None:
|
||||
self.provider.execute("DELETE FROM config WHERE key = ?", (key,))
|
||||
else:
|
||||
self.provider.execute(
|
||||
"INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, ?)",
|
||||
(key, str(value), datetime.now(UTC)),
|
||||
)
|
||||
|
||||
def delete(self, key):
|
||||
self.provider.execute("DELETE FROM config WHERE key = ?", (key,))
|
||||
|
||||
126
meshchatx/src/backend/database/legacy_migrator.py
Normal file
126
meshchatx/src/backend/database/legacy_migrator.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import os
|
||||
|
||||
|
||||
class LegacyMigrator:
|
||||
def __init__(self, provider, reticulum_config_dir, identity_hash_hex):
|
||||
self.provider = provider
|
||||
self.reticulum_config_dir = reticulum_config_dir
|
||||
self.identity_hash_hex = identity_hash_hex
|
||||
|
||||
def get_legacy_db_path(self):
|
||||
"""Detect the path to the legacy database based on the Reticulum config directory.
|
||||
"""
|
||||
possible_dirs = []
|
||||
if self.reticulum_config_dir:
|
||||
possible_dirs.append(self.reticulum_config_dir)
|
||||
|
||||
# Add common default locations
|
||||
home = os.path.expanduser("~")
|
||||
possible_dirs.append(os.path.join(home, ".reticulum-meshchat"))
|
||||
possible_dirs.append(os.path.join(home, ".reticulum"))
|
||||
|
||||
# Check each directory
|
||||
for config_dir in possible_dirs:
|
||||
legacy_path = os.path.join(config_dir, "identities", self.identity_hash_hex, "database.db")
|
||||
if os.path.exists(legacy_path):
|
||||
# Ensure it's not the same as our current DB path
|
||||
# (though this is unlikely given the different base directories)
|
||||
try:
|
||||
current_db_path = os.path.abspath(self.provider.db_path)
|
||||
if os.path.abspath(legacy_path) == current_db_path:
|
||||
continue
|
||||
except (AttributeError, OSError):
|
||||
# If we can't get the absolute path, just skip this check
|
||||
pass
|
||||
return legacy_path
|
||||
|
||||
return None
|
||||
|
||||
def should_migrate(self):
|
||||
"""Check if migration should be performed.
|
||||
Only migrates if the current database is empty and a legacy database exists.
|
||||
"""
|
||||
legacy_path = self.get_legacy_db_path()
|
||||
if not legacy_path:
|
||||
return False
|
||||
|
||||
# Check if current DB has any messages
|
||||
try:
|
||||
res = self.provider.fetchone("SELECT COUNT(*) as count FROM lxmf_messages")
|
||||
if res and res["count"] > 0:
|
||||
# Already have data, don't auto-migrate
|
||||
return False
|
||||
except Exception: # noqa: S110
|
||||
# Table doesn't exist yet, which is fine
|
||||
# We use a broad Exception here as the database might not even be initialized
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
def migrate(self):
|
||||
"""Perform the migration from the legacy database.
|
||||
"""
|
||||
legacy_path = self.get_legacy_db_path()
|
||||
if not legacy_path:
|
||||
return False
|
||||
|
||||
print(f"Detecting legacy database at {legacy_path}...")
|
||||
|
||||
try:
|
||||
# Attach the legacy database
|
||||
# We use a randomized alias to avoid collisions
|
||||
alias = f"legacy_{os.urandom(4).hex()}"
|
||||
self.provider.execute(f"ATTACH DATABASE '{legacy_path}' AS {alias}")
|
||||
|
||||
# Tables that existed in the legacy Peewee version
|
||||
tables_to_migrate = [
|
||||
"announces",
|
||||
"blocked_destinations",
|
||||
"config",
|
||||
"custom_destination_display_names",
|
||||
"favourite_destinations",
|
||||
"lxmf_conversation_read_state",
|
||||
"lxmf_messages",
|
||||
"lxmf_user_icons",
|
||||
"spam_keywords",
|
||||
]
|
||||
|
||||
print("Auto-migrating data from legacy database...")
|
||||
for table in tables_to_migrate:
|
||||
# Basic validation to ensure table name is from our whitelist
|
||||
if table not in tables_to_migrate:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Check if table exists in legacy DB
|
||||
# We use a f-string here for the alias and table name, which are controlled by us
|
||||
check_query = f"SELECT name FROM {alias}.sqlite_master WHERE type='table' AND name=?" # noqa: S608
|
||||
res = self.provider.fetchone(check_query, (table,))
|
||||
|
||||
if res:
|
||||
# Get columns from both databases to ensure compatibility
|
||||
# These PRAGMA calls are safe as they use controlled table/alias names
|
||||
legacy_columns = [row["name"] for row in self.provider.fetchall(f"PRAGMA {alias}.table_info({table})")]
|
||||
current_columns = [row["name"] for row in self.provider.fetchall(f"PRAGMA table_info({table})")]
|
||||
|
||||
# Find common columns
|
||||
common_columns = [col for col in legacy_columns if col in current_columns]
|
||||
|
||||
if common_columns:
|
||||
cols_str = ", ".join(common_columns)
|
||||
# We use INSERT OR IGNORE to avoid duplicates
|
||||
# The table and columns are controlled by us
|
||||
migrate_query = f"INSERT OR IGNORE INTO {table} ({cols_str}) SELECT {cols_str} FROM {alias}.{table}" # noqa: S608
|
||||
self.provider.execute(migrate_query)
|
||||
print(f" - Migrated table: {table} ({len(common_columns)} columns)")
|
||||
else:
|
||||
print(f" - Skipping table {table}: No common columns found")
|
||||
except Exception as e:
|
||||
print(f" - Failed to migrate table {table}: {e}")
|
||||
|
||||
self.provider.execute(f"DETACH DATABASE {alias}")
|
||||
print("Legacy migration completed successfully.")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Migration from legacy failed: {e}")
|
||||
return False
|
||||
146
meshchatx/src/backend/database/messages.py
Normal file
146
meshchatx/src/backend/database/messages.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from .provider import DatabaseProvider
|
||||
|
||||
|
||||
class MessageDAO:
|
||||
def __init__(self, provider: DatabaseProvider):
|
||||
self.provider = provider
|
||||
|
||||
def upsert_lxmf_message(self, data):
|
||||
# Ensure data is a dict if it's a sqlite3.Row
|
||||
if not isinstance(data, dict):
|
||||
data = dict(data)
|
||||
|
||||
# Ensure all required fields are present and handle defaults
|
||||
fields = [
|
||||
"hash", "source_hash", "destination_hash", "state", "progress",
|
||||
"is_incoming", "method", "delivery_attempts", "next_delivery_attempt_at",
|
||||
"title", "content", "fields", "timestamp", "rssi", "snr", "quality", "is_spam",
|
||||
]
|
||||
|
||||
columns = ", ".join(fields)
|
||||
placeholders = ", ".join(["?"] * len(fields))
|
||||
update_set = ", ".join([f"{f} = EXCLUDED.{f}" for f in fields if f != "hash"])
|
||||
|
||||
query = f"INSERT INTO lxmf_messages ({columns}, updated_at) VALUES ({placeholders}, ?) " \
|
||||
f"ON CONFLICT(hash) DO UPDATE SET {update_set}, updated_at = EXCLUDED.updated_at" # noqa: S608
|
||||
|
||||
params = []
|
||||
for f in fields:
|
||||
val = data.get(f)
|
||||
if f == "fields" and isinstance(val, dict):
|
||||
val = json.dumps(val)
|
||||
params.append(val)
|
||||
params.append(datetime.now(UTC).isoformat())
|
||||
|
||||
self.provider.execute(query, params)
|
||||
|
||||
def get_lxmf_message_by_hash(self, message_hash):
|
||||
return self.provider.fetchone("SELECT * FROM lxmf_messages WHERE hash = ?", (message_hash,))
|
||||
|
||||
def delete_lxmf_message_by_hash(self, message_hash):
|
||||
self.provider.execute("DELETE FROM lxmf_messages WHERE hash = ?", (message_hash,))
|
||||
|
||||
def get_conversation_messages(self, destination_hash, limit=100, offset=0):
|
||||
return self.provider.fetchall(
|
||||
"SELECT * FROM lxmf_messages WHERE destination_hash = ? OR source_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||
(destination_hash, destination_hash, limit, offset),
|
||||
)
|
||||
|
||||
def get_conversations(self):
|
||||
# This is a bit complex in raw SQL, we need the latest message for each destination
|
||||
query = """
|
||||
SELECT m1.* FROM lxmf_messages m1
|
||||
JOIN (
|
||||
SELECT
|
||||
CASE WHEN is_incoming = 1 THEN source_hash ELSE destination_hash END as peer_hash,
|
||||
MAX(timestamp) as max_ts
|
||||
FROM lxmf_messages
|
||||
GROUP BY peer_hash
|
||||
) m2 ON (CASE WHEN m1.is_incoming = 1 THEN m1.source_hash ELSE m1.destination_hash END = m2.peer_hash
|
||||
AND m1.timestamp = m2.max_ts)
|
||||
ORDER BY m1.timestamp DESC
|
||||
"""
|
||||
return self.provider.fetchall(query)
|
||||
|
||||
def mark_conversation_as_read(self, destination_hash):
|
||||
now = datetime.now(UTC).isoformat()
|
||||
self.provider.execute(
|
||||
"INSERT OR REPLACE INTO lxmf_conversation_read_state (destination_hash, last_read_at, updated_at) VALUES (?, ?, ?)",
|
||||
(destination_hash, now, now),
|
||||
)
|
||||
|
||||
def is_conversation_unread(self, destination_hash):
|
||||
row = self.provider.fetchone("""
|
||||
SELECT m.timestamp, r.last_read_at
|
||||
FROM lxmf_messages m
|
||||
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = ?
|
||||
WHERE (m.destination_hash = ? OR m.source_hash = ?)
|
||||
ORDER BY m.timestamp DESC LIMIT 1
|
||||
""", (destination_hash, destination_hash, destination_hash))
|
||||
|
||||
if not row:
|
||||
return False
|
||||
if not row["last_read_at"]:
|
||||
return True
|
||||
|
||||
last_read_at = datetime.fromisoformat(row["last_read_at"])
|
||||
if last_read_at.tzinfo is None:
|
||||
last_read_at = last_read_at.replace(tzinfo=UTC)
|
||||
|
||||
return row["timestamp"] > last_read_at.timestamp()
|
||||
|
||||
def mark_stuck_messages_as_failed(self):
|
||||
self.provider.execute("""
|
||||
UPDATE lxmf_messages
|
||||
SET state = 'failed', updated_at = ?
|
||||
WHERE state = 'outbound'
|
||||
OR (state = 'sent' AND method = 'opportunistic')
|
||||
OR state = 'sending'
|
||||
""", (datetime.now(UTC).isoformat(),))
|
||||
|
||||
def get_failed_messages_for_destination(self, destination_hash):
|
||||
return self.provider.fetchall(
|
||||
"SELECT * FROM lxmf_messages WHERE state = 'failed' AND destination_hash = ? ORDER BY id ASC",
|
||||
(destination_hash,),
|
||||
)
|
||||
|
||||
def get_failed_messages_count(self, destination_hash):
|
||||
row = self.provider.fetchone(
|
||||
"SELECT COUNT(*) as count FROM lxmf_messages WHERE state = 'failed' AND destination_hash = ?",
|
||||
(destination_hash,),
|
||||
)
|
||||
return row["count"] if row else 0
|
||||
|
||||
# Forwarding Mappings
|
||||
def get_forwarding_mapping(self, alias_hash=None, original_sender_hash=None, final_recipient_hash=None):
|
||||
if alias_hash:
|
||||
return self.provider.fetchone("SELECT * FROM lxmf_forwarding_mappings WHERE alias_hash = ?", (alias_hash,))
|
||||
if original_sender_hash and final_recipient_hash:
|
||||
return self.provider.fetchone(
|
||||
"SELECT * FROM lxmf_forwarding_mappings WHERE original_sender_hash = ? AND final_recipient_hash = ?",
|
||||
(original_sender_hash, final_recipient_hash),
|
||||
)
|
||||
return None
|
||||
|
||||
def create_forwarding_mapping(self, data):
|
||||
# Ensure data is a dict if it's a sqlite3.Row
|
||||
if not isinstance(data, dict):
|
||||
data = dict(data)
|
||||
|
||||
fields = [
|
||||
"alias_identity_private_key", "alias_hash", "original_sender_hash",
|
||||
"final_recipient_hash", "original_destination_hash",
|
||||
]
|
||||
columns = ", ".join(fields)
|
||||
placeholders = ", ".join(["?"] * len(fields))
|
||||
query = f"INSERT INTO lxmf_forwarding_mappings ({columns}, created_at) VALUES ({placeholders}, ?)" # noqa: S608
|
||||
params = [data.get(f) for f in fields]
|
||||
params.append(datetime.now(UTC).isoformat())
|
||||
self.provider.execute(query, params)
|
||||
|
||||
def get_all_forwarding_mappings(self):
|
||||
return self.provider.fetchall("SELECT * FROM lxmf_forwarding_mappings")
|
||||
|
||||
154
meshchatx/src/backend/database/misc.py
Normal file
154
meshchatx/src/backend/database/misc.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from .provider import DatabaseProvider
|
||||
|
||||
|
||||
class MiscDAO:
|
||||
def __init__(self, provider: DatabaseProvider):
|
||||
self.provider = provider
|
||||
|
||||
# Blocked Destinations
|
||||
def add_blocked_destination(self, destination_hash):
|
||||
self.provider.execute(
|
||||
"INSERT OR IGNORE INTO blocked_destinations (destination_hash, updated_at) VALUES (?, ?)",
|
||||
(destination_hash, datetime.now(UTC)),
|
||||
)
|
||||
|
||||
def is_destination_blocked(self, destination_hash):
|
||||
return self.provider.fetchone("SELECT 1 FROM blocked_destinations WHERE destination_hash = ?", (destination_hash,)) is not None
|
||||
|
||||
def get_blocked_destinations(self):
|
||||
return self.provider.fetchall("SELECT * FROM blocked_destinations")
|
||||
|
||||
def delete_blocked_destination(self, destination_hash):
|
||||
self.provider.execute("DELETE FROM blocked_destinations WHERE destination_hash = ?", (destination_hash,))
|
||||
|
||||
# Spam Keywords
|
||||
def add_spam_keyword(self, keyword):
|
||||
self.provider.execute(
|
||||
"INSERT OR IGNORE INTO spam_keywords (keyword, updated_at) VALUES (?, ?)",
|
||||
(keyword, datetime.now(UTC)),
|
||||
)
|
||||
|
||||
def get_spam_keywords(self):
|
||||
return self.provider.fetchall("SELECT * FROM spam_keywords")
|
||||
|
||||
def delete_spam_keyword(self, keyword_id):
|
||||
self.provider.execute("DELETE FROM spam_keywords WHERE id = ?", (keyword_id,))
|
||||
|
||||
def check_spam_keywords(self, title, content):
|
||||
keywords = self.get_spam_keywords()
|
||||
search_text = (title + " " + content).lower()
|
||||
for kw in keywords:
|
||||
if kw["keyword"].lower() in search_text:
|
||||
return True
|
||||
return False
|
||||
|
||||
# User Icons
|
||||
def update_lxmf_user_icon(self, destination_hash, icon_name, foreground_colour, background_colour):
|
||||
now = datetime.now(UTC)
|
||||
self.provider.execute("""
|
||||
INSERT INTO lxmf_user_icons (destination_hash, icon_name, foreground_colour, background_colour, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(destination_hash) DO UPDATE SET
|
||||
icon_name = EXCLUDED.icon_name,
|
||||
foreground_colour = EXCLUDED.foreground_colour,
|
||||
background_colour = EXCLUDED.background_colour,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
""", (destination_hash, icon_name, foreground_colour, background_colour, now))
|
||||
|
||||
def get_user_icon(self, destination_hash):
|
||||
return self.provider.fetchone("SELECT * FROM lxmf_user_icons WHERE destination_hash = ?", (destination_hash,))
|
||||
|
||||
# Forwarding Rules
|
||||
def get_forwarding_rules(self, identity_hash=None, active_only=False):
|
||||
query = "SELECT * FROM lxmf_forwarding_rules WHERE 1=1"
|
||||
params = []
|
||||
if identity_hash:
|
||||
query += " AND (identity_hash = ? OR identity_hash IS NULL)"
|
||||
params.append(identity_hash)
|
||||
if active_only:
|
||||
query += " AND is_active = 1"
|
||||
return self.provider.fetchall(query, params)
|
||||
|
||||
def create_forwarding_rule(self, identity_hash, forward_to_hash, source_filter_hash, is_active=True):
|
||||
now = datetime.now(UTC)
|
||||
self.provider.execute(
|
||||
"INSERT INTO lxmf_forwarding_rules (identity_hash, forward_to_hash, source_filter_hash, is_active, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(identity_hash, forward_to_hash, source_filter_hash, 1 if is_active else 0, now),
|
||||
)
|
||||
|
||||
def delete_forwarding_rule(self, rule_id):
|
||||
self.provider.execute("DELETE FROM lxmf_forwarding_rules WHERE id = ?", (rule_id,))
|
||||
|
||||
def toggle_forwarding_rule(self, rule_id):
|
||||
self.provider.execute("UPDATE lxmf_forwarding_rules SET is_active = NOT is_active WHERE id = ?", (rule_id,))
|
||||
|
||||
# Archived Pages
|
||||
def archive_page(self, destination_hash, page_path, content, page_hash):
|
||||
self.provider.execute(
|
||||
"INSERT INTO archived_pages (destination_hash, page_path, content, hash) VALUES (?, ?, ?, ?)",
|
||||
(destination_hash, page_path, content, page_hash),
|
||||
)
|
||||
|
||||
def get_archived_page_versions(self, destination_hash, page_path):
|
||||
return self.provider.fetchall(
|
||||
"SELECT * FROM archived_pages WHERE destination_hash = ? AND page_path = ? ORDER BY created_at DESC",
|
||||
(destination_hash, page_path),
|
||||
)
|
||||
|
||||
def get_archived_pages_paginated(self, destination_hash=None, query=None):
|
||||
sql = "SELECT * FROM archived_pages WHERE 1=1"
|
||||
params = []
|
||||
if destination_hash:
|
||||
sql += " AND destination_hash = ?"
|
||||
params.append(destination_hash)
|
||||
if query:
|
||||
like_term = f"%{query}%"
|
||||
sql += " AND (destination_hash LIKE ? OR page_path LIKE ? OR content LIKE ?)"
|
||||
params.extend([like_term, like_term, like_term])
|
||||
|
||||
sql += " ORDER BY created_at DESC"
|
||||
return self.provider.fetchall(sql, params)
|
||||
|
||||
def delete_archived_pages(self, destination_hash=None, page_path=None):
|
||||
if destination_hash and page_path:
|
||||
self.provider.execute("DELETE FROM archived_pages WHERE destination_hash = ? AND page_path = ?", (destination_hash, page_path))
|
||||
else:
|
||||
self.provider.execute("DELETE FROM archived_pages")
|
||||
|
||||
# Crawl Tasks
|
||||
def upsert_crawl_task(self, destination_hash, page_path, status="pending", retry_count=0):
|
||||
self.provider.execute("""
|
||||
INSERT INTO crawl_tasks (destination_hash, page_path, status, retry_count)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(destination_hash, page_path) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
retry_count = EXCLUDED.retry_count
|
||||
""", (destination_hash, page_path, status, retry_count))
|
||||
|
||||
def get_pending_crawl_tasks(self):
|
||||
return self.provider.fetchall("SELECT * FROM crawl_tasks WHERE status = 'pending'")
|
||||
|
||||
def update_crawl_task(self, task_id, **kwargs):
|
||||
allowed_keys = {"destination_hash", "page_path", "status", "retry_count", "updated_at"}
|
||||
filtered_kwargs = {k: v for k, v in kwargs.items() if k in allowed_keys}
|
||||
|
||||
if not filtered_kwargs:
|
||||
return
|
||||
|
||||
set_clause = ", ".join([f"{k} = ?" for k in filtered_kwargs])
|
||||
params = list(filtered_kwargs.values())
|
||||
params.append(task_id)
|
||||
query = f"UPDATE crawl_tasks SET {set_clause} WHERE id = ?" # noqa: S608
|
||||
self.provider.execute(query, params)
|
||||
|
||||
def get_pending_or_failed_crawl_tasks(self, max_retries, max_concurrent):
|
||||
return self.provider.fetchall(
|
||||
"SELECT * FROM crawl_tasks WHERE status IN ('pending', 'failed') AND retry_count < ? LIMIT ?",
|
||||
(max_retries, max_concurrent),
|
||||
)
|
||||
|
||||
def get_archived_page_by_id(self, archive_id):
|
||||
return self.provider.fetchone("SELECT * FROM archived_pages WHERE id = ?", (archive_id,))
|
||||
|
||||
65
meshchatx/src/backend/database/provider.py
Normal file
65
meshchatx/src/backend/database/provider.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
|
||||
class DatabaseProvider:
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __init__(self, db_path=None):
|
||||
self.db_path = db_path
|
||||
self._local = threading.local()
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, db_path=None):
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
if db_path is None:
|
||||
msg = "Database path must be provided for the first initialization"
|
||||
raise ValueError(msg)
|
||||
cls._instance = cls(db_path)
|
||||
return cls._instance
|
||||
|
||||
@property
|
||||
def connection(self):
|
||||
if not hasattr(self._local, "connection"):
|
||||
self._local.connection = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
self._local.connection.row_factory = sqlite3.Row
|
||||
# Enable WAL mode for better concurrency
|
||||
self._local.connection.execute("PRAGMA journal_mode=WAL")
|
||||
return self._local.connection
|
||||
|
||||
def execute(self, query, params=None):
|
||||
cursor = self.connection.cursor()
|
||||
if params:
|
||||
cursor.execute(query, params)
|
||||
else:
|
||||
cursor.execute(query)
|
||||
self.connection.commit()
|
||||
return cursor
|
||||
|
||||
def fetchone(self, query, params=None):
|
||||
cursor = self.execute(query, params)
|
||||
return cursor.fetchone()
|
||||
|
||||
def fetchall(self, query, params=None):
|
||||
cursor = self.execute(query, params)
|
||||
return cursor.fetchall()
|
||||
|
||||
def close(self):
|
||||
if hasattr(self._local, "connection"):
|
||||
self._local.connection.close()
|
||||
del self._local.connection
|
||||
|
||||
def vacuum(self):
|
||||
self.execute("VACUUM")
|
||||
|
||||
def integrity_check(self):
|
||||
return self.fetchall("PRAGMA integrity_check")
|
||||
|
||||
def quick_check(self):
|
||||
return self.fetchall("PRAGMA quick_check")
|
||||
|
||||
def checkpoint(self):
|
||||
return self.fetchall("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
|
||||
317
meshchatx/src/backend/database/schema.py
Normal file
317
meshchatx/src/backend/database/schema.py
Normal file
@@ -0,0 +1,317 @@
|
||||
from .provider import DatabaseProvider
|
||||
|
||||
|
||||
class DatabaseSchema:
|
||||
LATEST_VERSION = 12
|
||||
|
||||
def __init__(self, provider: DatabaseProvider):
|
||||
self.provider = provider
|
||||
|
||||
def initialize(self):
|
||||
# Create core tables if they don't exist
|
||||
self._create_initial_tables()
|
||||
|
||||
# Run migrations
|
||||
current_version = self._get_current_version()
|
||||
self.migrate(current_version)
|
||||
|
||||
def _get_current_version(self):
|
||||
row = self.provider.fetchone("SELECT value FROM config WHERE key = ?", ("database_version",))
|
||||
if row:
|
||||
return int(row["value"])
|
||||
return 0
|
||||
|
||||
def _create_initial_tables(self):
|
||||
# We create the config table first so we can track version
|
||||
self.provider.execute("""
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE,
|
||||
value TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Other essential tables that were present from version 1
|
||||
# Peewee automatically creates tables if they don't exist.
|
||||
# Here we define the full schema for all tables as they should be now.
|
||||
|
||||
tables = {
|
||||
"announces": """
|
||||
CREATE TABLE IF NOT EXISTS announces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
destination_hash TEXT UNIQUE,
|
||||
aspect TEXT,
|
||||
identity_hash TEXT,
|
||||
identity_public_key TEXT,
|
||||
app_data TEXT,
|
||||
rssi INTEGER,
|
||||
snr REAL,
|
||||
quality REAL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"custom_destination_display_names": """
|
||||
CREATE TABLE IF NOT EXISTS custom_destination_display_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
destination_hash TEXT UNIQUE,
|
||||
display_name TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"favourite_destinations": """
|
||||
CREATE TABLE IF NOT EXISTS favourite_destinations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
destination_hash TEXT UNIQUE,
|
||||
display_name TEXT,
|
||||
aspect TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"lxmf_messages": """
|
||||
CREATE TABLE IF NOT EXISTS lxmf_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hash TEXT UNIQUE,
|
||||
source_hash TEXT,
|
||||
destination_hash TEXT,
|
||||
state TEXT,
|
||||
progress REAL,
|
||||
is_incoming INTEGER,
|
||||
method TEXT,
|
||||
delivery_attempts INTEGER DEFAULT 0,
|
||||
next_delivery_attempt_at REAL,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
fields TEXT,
|
||||
timestamp REAL,
|
||||
rssi INTEGER,
|
||||
snr REAL,
|
||||
quality REAL,
|
||||
is_spam INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"lxmf_conversation_read_state": """
|
||||
CREATE TABLE IF NOT EXISTS lxmf_conversation_read_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
destination_hash TEXT UNIQUE,
|
||||
last_read_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"lxmf_user_icons": """
|
||||
CREATE TABLE IF NOT EXISTS lxmf_user_icons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
destination_hash TEXT UNIQUE,
|
||||
icon_name TEXT,
|
||||
foreground_colour TEXT,
|
||||
background_colour TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"blocked_destinations": """
|
||||
CREATE TABLE IF NOT EXISTS blocked_destinations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
destination_hash TEXT UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"spam_keywords": """
|
||||
CREATE TABLE IF NOT EXISTS spam_keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"archived_pages": """
|
||||
CREATE TABLE IF NOT EXISTS archived_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
destination_hash TEXT,
|
||||
page_path TEXT,
|
||||
content TEXT,
|
||||
hash TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"crawl_tasks": """
|
||||
CREATE TABLE IF NOT EXISTS crawl_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
destination_hash TEXT,
|
||||
page_path TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
last_retry_at DATETIME,
|
||||
next_retry_at DATETIME,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(destination_hash, page_path)
|
||||
)
|
||||
""",
|
||||
"lxmf_forwarding_rules": """
|
||||
CREATE TABLE IF NOT EXISTS lxmf_forwarding_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
identity_hash TEXT,
|
||||
forward_to_hash TEXT,
|
||||
source_filter_hash TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"lxmf_forwarding_mappings": """
|
||||
CREATE TABLE IF NOT EXISTS lxmf_forwarding_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
alias_identity_private_key TEXT,
|
||||
alias_hash TEXT UNIQUE,
|
||||
original_sender_hash TEXT,
|
||||
final_recipient_hash TEXT,
|
||||
original_destination_hash TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"call_history": """
|
||||
CREATE TABLE IF NOT EXISTS call_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
remote_identity_hash TEXT,
|
||||
remote_identity_name TEXT,
|
||||
is_incoming INTEGER,
|
||||
status TEXT,
|
||||
duration_seconds INTEGER,
|
||||
timestamp REAL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
}
|
||||
|
||||
for table_name, create_sql in tables.items():
|
||||
self.provider.execute(create_sql)
|
||||
# Create indexes that were present
|
||||
if table_name == "announces":
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_announces_aspect ON announces(aspect)")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_announces_identity_hash ON announces(identity_hash)")
|
||||
elif table_name == "lxmf_messages":
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_messages_source_hash ON lxmf_messages(source_hash)")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_messages_destination_hash ON lxmf_messages(destination_hash)")
|
||||
elif table_name == "blocked_destinations":
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_blocked_destinations_hash ON blocked_destinations(destination_hash)")
|
||||
elif table_name == "spam_keywords":
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_spam_keywords_keyword ON spam_keywords(keyword)")
|
||||
|
||||
def migrate(self, current_version):
|
||||
if current_version < 7:
|
||||
self.provider.execute("""
|
||||
CREATE TABLE IF NOT EXISTS archived_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
destination_hash TEXT,
|
||||
page_path TEXT,
|
||||
content TEXT,
|
||||
hash TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_archived_pages_destination_hash ON archived_pages(destination_hash)")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_archived_pages_page_path ON archived_pages(page_path)")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_archived_pages_hash ON archived_pages(hash)")
|
||||
|
||||
if current_version < 8:
|
||||
self.provider.execute("""
|
||||
CREATE TABLE IF NOT EXISTS crawl_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
destination_hash TEXT,
|
||||
page_path TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
last_retry_at DATETIME,
|
||||
next_retry_at DATETIME,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_crawl_tasks_destination_hash ON crawl_tasks(destination_hash)")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_crawl_tasks_page_path ON crawl_tasks(page_path)")
|
||||
|
||||
if current_version < 9:
|
||||
self.provider.execute("""
|
||||
CREATE TABLE IF NOT EXISTS lxmf_forwarding_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
identity_hash TEXT,
|
||||
forward_to_hash TEXT,
|
||||
source_filter_hash TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_rules_identity_hash ON lxmf_forwarding_rules(identity_hash)")
|
||||
|
||||
self.provider.execute("""
|
||||
CREATE TABLE IF NOT EXISTS lxmf_forwarding_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
alias_identity_private_key TEXT,
|
||||
alias_hash TEXT UNIQUE,
|
||||
original_sender_hash TEXT,
|
||||
final_recipient_hash TEXT,
|
||||
original_destination_hash TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_alias_hash ON lxmf_forwarding_mappings(alias_hash)")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_sender_hash ON lxmf_forwarding_mappings(original_sender_hash)")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_recipient_hash ON lxmf_forwarding_mappings(final_recipient_hash)")
|
||||
|
||||
if current_version < 10:
|
||||
# Ensure unique constraints exist for ON CONFLICT clauses
|
||||
# SQLite doesn't support adding UNIQUE constraints via ALTER TABLE,
|
||||
# but a UNIQUE index works for ON CONFLICT.
|
||||
|
||||
# Clean up duplicates before adding unique indexes
|
||||
self.provider.execute("DELETE FROM announces WHERE id NOT IN (SELECT MAX(id) FROM announces GROUP BY destination_hash)")
|
||||
self.provider.execute("DELETE FROM crawl_tasks WHERE id NOT IN (SELECT MAX(id) FROM crawl_tasks GROUP BY destination_hash, page_path)")
|
||||
self.provider.execute("DELETE FROM custom_destination_display_names WHERE id NOT IN (SELECT MAX(id) FROM custom_destination_display_names GROUP BY destination_hash)")
|
||||
self.provider.execute("DELETE FROM favourite_destinations WHERE id NOT IN (SELECT MAX(id) FROM favourite_destinations GROUP BY destination_hash)")
|
||||
self.provider.execute("DELETE FROM lxmf_user_icons WHERE id NOT IN (SELECT MAX(id) FROM lxmf_user_icons GROUP BY destination_hash)")
|
||||
self.provider.execute("DELETE FROM lxmf_conversation_read_state WHERE id NOT IN (SELECT MAX(id) FROM lxmf_conversation_read_state GROUP BY destination_hash)")
|
||||
self.provider.execute("DELETE FROM lxmf_messages WHERE id NOT IN (SELECT MAX(id) FROM lxmf_messages GROUP BY hash)")
|
||||
|
||||
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_announces_destination_hash_unique ON announces(destination_hash)")
|
||||
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_crawl_tasks_destination_path_unique ON crawl_tasks(destination_hash, page_path)")
|
||||
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_display_names_dest_hash_unique ON custom_destination_display_names(destination_hash)")
|
||||
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_favourite_destinations_dest_hash_unique ON favourite_destinations(destination_hash)")
|
||||
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_messages_hash_unique ON lxmf_messages(hash)")
|
||||
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_user_icons_dest_hash_unique ON lxmf_user_icons(destination_hash)")
|
||||
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_conversation_read_state_dest_hash_unique ON lxmf_conversation_read_state(destination_hash)")
|
||||
|
||||
if current_version < 11:
|
||||
# Add is_spam column to lxmf_messages if it doesn't exist
|
||||
try:
|
||||
self.provider.execute("ALTER TABLE lxmf_messages ADD COLUMN is_spam INTEGER DEFAULT 0")
|
||||
except Exception:
|
||||
# Column might already exist if table was created with newest schema
|
||||
pass
|
||||
|
||||
if current_version < 12:
|
||||
self.provider.execute("""
|
||||
CREATE TABLE IF NOT EXISTS call_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
remote_identity_hash TEXT,
|
||||
remote_identity_name TEXT,
|
||||
is_incoming INTEGER,
|
||||
status TEXT,
|
||||
duration_seconds INTEGER,
|
||||
timestamp REAL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_call_history_remote_hash ON call_history(remote_identity_hash)")
|
||||
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_call_history_timestamp ON call_history(timestamp)")
|
||||
|
||||
# Update version in config
|
||||
self.provider.execute("INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", ("database_version", str(self.LATEST_VERSION)))
|
||||
|
||||
44
meshchatx/src/backend/database/telephone.py
Normal file
44
meshchatx/src/backend/database/telephone.py
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
from .provider import DatabaseProvider
|
||||
|
||||
|
||||
class TelephoneDAO:
|
||||
def __init__(self, provider: DatabaseProvider):
|
||||
self.provider = provider
|
||||
|
||||
def add_call_history(
|
||||
self,
|
||||
remote_identity_hash,
|
||||
remote_identity_name,
|
||||
is_incoming,
|
||||
status,
|
||||
duration_seconds,
|
||||
timestamp,
|
||||
):
|
||||
self.provider.execute(
|
||||
"""
|
||||
INSERT INTO call_history (
|
||||
remote_identity_hash,
|
||||
remote_identity_name,
|
||||
is_incoming,
|
||||
status,
|
||||
duration_seconds,
|
||||
timestamp
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
remote_identity_hash,
|
||||
remote_identity_name,
|
||||
1 if is_incoming else 0,
|
||||
status,
|
||||
duration_seconds,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
|
||||
def get_call_history(self, limit=10):
|
||||
return self.provider.fetchall(
|
||||
"SELECT * FROM call_history ORDER BY timestamp DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
48
meshchatx/src/backend/forwarding_manager.py
Normal file
48
meshchatx/src/backend/forwarding_manager.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import base64
|
||||
|
||||
import RNS
|
||||
|
||||
from .database import Database
|
||||
|
||||
|
||||
class ForwardingManager:
|
||||
def __init__(self, db: Database, message_router):
|
||||
self.db = db
|
||||
self.message_router = message_router
|
||||
self.forwarding_destinations = {}
|
||||
|
||||
def load_aliases(self):
|
||||
mappings = self.db.messages.get_all_forwarding_mappings()
|
||||
for mapping in mappings:
|
||||
try:
|
||||
private_key_bytes = base64.b64decode(mapping["alias_identity_private_key"])
|
||||
alias_identity = RNS.Identity.from_bytes(private_key_bytes)
|
||||
alias_destination = self.message_router.register_delivery_identity(identity=alias_identity)
|
||||
self.forwarding_destinations[mapping["alias_hash"]] = alias_destination
|
||||
except Exception as e:
|
||||
print(f"Failed to load forwarding alias {mapping['alias_hash']}: {e}")
|
||||
|
||||
def get_or_create_mapping(self, source_hash, final_recipient_hash, original_destination_hash):
|
||||
mapping = self.db.messages.get_forwarding_mapping(
|
||||
original_sender_hash=source_hash,
|
||||
final_recipient_hash=final_recipient_hash,
|
||||
)
|
||||
|
||||
if not mapping:
|
||||
alias_identity = RNS.Identity()
|
||||
alias_hash = alias_identity.hash.hex()
|
||||
|
||||
alias_destination = self.message_router.register_delivery_identity(alias_identity)
|
||||
self.forwarding_destinations[alias_hash] = alias_destination
|
||||
|
||||
data = {
|
||||
"alias_identity_private_key": base64.b64encode(alias_identity.get_private_key()).decode(),
|
||||
"alias_hash": alias_hash,
|
||||
"original_sender_hash": source_hash,
|
||||
"final_recipient_hash": final_recipient_hash,
|
||||
"original_destination_hash": original_destination_hash,
|
||||
}
|
||||
self.db.messages.create_forwarding_mapping(data)
|
||||
return data
|
||||
return mapping
|
||||
|
||||
91
meshchatx/src/backend/interface_config_parser.py
Normal file
91
meshchatx/src/backend/interface_config_parser.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import RNS.vendor.configobj
|
||||
|
||||
|
||||
class InterfaceConfigParser:
|
||||
@staticmethod
|
||||
def parse(text):
|
||||
# get lines from provided text
|
||||
lines = text.splitlines()
|
||||
stripped_lines = [line.strip() for line in lines]
|
||||
|
||||
# ensure [interfaces] section exists
|
||||
if "[interfaces]" not in stripped_lines:
|
||||
lines.insert(0, "[interfaces]")
|
||||
stripped_lines.insert(0, "[interfaces]")
|
||||
|
||||
try:
|
||||
# parse lines as rns config object
|
||||
config = RNS.vendor.configobj.ConfigObj(lines)
|
||||
except Exception as e:
|
||||
print(f"Failed to parse interface config with ConfigObj: {e}")
|
||||
return InterfaceConfigParser._parse_best_effort(lines)
|
||||
|
||||
# get interfaces from config
|
||||
config_interfaces = config.get("interfaces", {})
|
||||
if config_interfaces is None:
|
||||
return []
|
||||
|
||||
# process interfaces
|
||||
interfaces = []
|
||||
for interface_name in config_interfaces:
|
||||
# ensure interface has a name
|
||||
interface_config = config_interfaces[interface_name]
|
||||
interface_config["name"] = interface_name
|
||||
interfaces.append(interface_config)
|
||||
|
||||
return interfaces
|
||||
|
||||
@staticmethod
|
||||
def _parse_best_effort(lines):
|
||||
interfaces = []
|
||||
current_interface_name = None
|
||||
current_interface = {}
|
||||
current_sub_name = None
|
||||
current_sub = None
|
||||
|
||||
def commit_sub():
|
||||
nonlocal current_sub_name, current_sub
|
||||
if current_sub_name and current_sub is not None:
|
||||
current_interface[current_sub_name] = current_sub
|
||||
current_sub_name = None
|
||||
current_sub = None
|
||||
|
||||
def commit_interface():
|
||||
nonlocal current_interface_name, current_interface
|
||||
if current_interface_name:
|
||||
# shallow copy to avoid future mutation
|
||||
interfaces.append(dict(current_interface))
|
||||
current_interface_name = None
|
||||
current_interface = {}
|
||||
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if line == "" or line.startswith("#"):
|
||||
continue
|
||||
|
||||
if line.lower() == "[interfaces]":
|
||||
continue
|
||||
|
||||
if line.startswith("[[[") and line.endswith("]]]"):
|
||||
commit_sub()
|
||||
current_sub_name = line[3:-3].strip()
|
||||
current_sub = {}
|
||||
continue
|
||||
|
||||
if line.startswith("[[") and line.endswith("]]"):
|
||||
commit_sub()
|
||||
commit_interface()
|
||||
current_interface_name = line[2:-2].strip()
|
||||
current_interface = {"name": current_interface_name}
|
||||
continue
|
||||
|
||||
if "=" in line and current_interface_name is not None:
|
||||
key, value = line.split("=", 1)
|
||||
target = current_sub if current_sub is not None else current_interface
|
||||
target[key.strip()] = value.strip()
|
||||
|
||||
# commit any pending sections
|
||||
commit_sub()
|
||||
commit_interface()
|
||||
|
||||
return interfaces
|
||||
11
meshchatx/src/backend/interface_editor.py
Normal file
11
meshchatx/src/backend/interface_editor.py
Normal file
@@ -0,0 +1,11 @@
|
||||
class InterfaceEditor:
|
||||
@staticmethod
|
||||
def update_value(interface_details: dict, data: dict, key: str):
|
||||
# update value if provided and not empty
|
||||
value = data.get(key)
|
||||
if value is not None and value != "":
|
||||
interface_details[key] = value
|
||||
return
|
||||
|
||||
# otherwise remove existing value
|
||||
interface_details.pop(key, None)
|
||||
136
meshchatx/src/backend/interfaces/WebsocketClientInterface.py
Normal file
136
meshchatx/src/backend/interfaces/WebsocketClientInterface.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import threading
|
||||
import time
|
||||
|
||||
import RNS
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from websockets.sync.client import connect
|
||||
from websockets.sync.connection import Connection
|
||||
|
||||
|
||||
class WebsocketClientInterface(Interface):
|
||||
# TODO: required?
|
||||
DEFAULT_IFAC_SIZE = 16
|
||||
|
||||
RECONNECT_DELAY_SECONDS = 5
|
||||
|
||||
def __str__(self):
|
||||
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
|
||||
|
||||
def __init__(self, owner, configuration, websocket: Connection = None):
|
||||
super().__init__()
|
||||
|
||||
self.owner = owner
|
||||
self.parent_interface = None
|
||||
|
||||
self.IN = True
|
||||
self.OUT = False
|
||||
self.HW_MTU = 262144 # 256KiB
|
||||
self.bitrate = 1_000_000_000 # 1Gbps
|
||||
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||
|
||||
# parse config
|
||||
ifconf = Interface.get_config_obj(configuration)
|
||||
self.name = ifconf.get("name")
|
||||
self.target_url = ifconf.get("target_url", None)
|
||||
|
||||
# ensure target url is provided
|
||||
if self.target_url is None:
|
||||
msg = f"target_url is required for interface '{self.name}'"
|
||||
raise SystemError(msg)
|
||||
|
||||
# connect to websocket server if an existing connection was not provided
|
||||
self.websocket = websocket
|
||||
if self.websocket is None:
|
||||
thread = threading.Thread(target=self.connect)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# called when a full packet has been received over the websocket
|
||||
def process_incoming(self, data):
|
||||
# do nothing if offline or detached
|
||||
if not self.online or self.detached:
|
||||
return
|
||||
|
||||
# update received bytes counter
|
||||
self.rxb += len(data)
|
||||
|
||||
# update received bytes counter for parent interface
|
||||
if self.parent_interface is not None:
|
||||
self.parent_interface.rxb += len(data)
|
||||
|
||||
# send received data to transport instance
|
||||
self.owner.inbound(data, self)
|
||||
|
||||
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
|
||||
def process_outgoing(self, data):
|
||||
# do nothing if offline or detached
|
||||
if not self.online or self.detached:
|
||||
return
|
||||
|
||||
# send to websocket server
|
||||
try:
|
||||
self.websocket.send(data)
|
||||
except Exception as e:
|
||||
RNS.log(
|
||||
f"Exception occurred while transmitting via {self!s}",
|
||||
RNS.LOG_ERROR,
|
||||
)
|
||||
RNS.log(f"The contained exception was: {e!s}", RNS.LOG_ERROR)
|
||||
return
|
||||
|
||||
# update sent bytes counter
|
||||
self.txb += len(data)
|
||||
|
||||
# update received bytes counter for parent interface
|
||||
if self.parent_interface is not None:
|
||||
self.parent_interface.txb += len(data)
|
||||
|
||||
# connect to the configured websocket server
|
||||
def connect(self):
|
||||
# do nothing if interface is detached
|
||||
if self.detached:
|
||||
return
|
||||
|
||||
# connect to websocket server
|
||||
try:
|
||||
RNS.log(f"Connecting to Websocket for {self!s}...", RNS.LOG_DEBUG)
|
||||
self.websocket = connect(
|
||||
f"{self.target_url}",
|
||||
max_size=None,
|
||||
compression=None,
|
||||
)
|
||||
RNS.log(f"Connected to Websocket for {self!s}", RNS.LOG_DEBUG)
|
||||
self.read_loop()
|
||||
except Exception as e:
|
||||
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
|
||||
|
||||
# auto reconnect after delay
|
||||
RNS.log(f"Websocket disconnected for {self!s}...", RNS.LOG_DEBUG)
|
||||
time.sleep(self.RECONNECT_DELAY_SECONDS)
|
||||
self.connect()
|
||||
|
||||
def read_loop(self):
|
||||
self.online = True
|
||||
|
||||
try:
|
||||
for message in self.websocket:
|
||||
self.process_incoming(message)
|
||||
except Exception as e:
|
||||
RNS.log(f"{self} read loop error: {e}", RNS.LOG_ERROR)
|
||||
|
||||
self.online = False
|
||||
|
||||
def detach(self):
|
||||
# mark as offline
|
||||
self.online = False
|
||||
|
||||
# close websocket
|
||||
if self.websocket is not None:
|
||||
self.websocket.close()
|
||||
|
||||
# mark as detached
|
||||
self.detached = True
|
||||
|
||||
|
||||
# set interface class RNS should use when importing this external interface
|
||||
interface_class = WebsocketClientInterface
|
||||
165
meshchatx/src/backend/interfaces/WebsocketServerInterface.py
Normal file
165
meshchatx/src/backend/interfaces/WebsocketServerInterface.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import threading
|
||||
import time
|
||||
|
||||
import RNS
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from src.backend.interfaces.WebsocketClientInterface import WebsocketClientInterface
|
||||
from websockets.sync.server import Server, ServerConnection, serve
|
||||
|
||||
|
||||
class WebsocketServerInterface(Interface):
|
||||
# TODO: required?
|
||||
DEFAULT_IFAC_SIZE = 16
|
||||
|
||||
RESTART_DELAY_SECONDS = 5
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
|
||||
)
|
||||
|
||||
def __init__(self, owner, configuration):
|
||||
super().__init__()
|
||||
|
||||
self.owner = owner
|
||||
|
||||
self.IN = True
|
||||
self.OUT = False
|
||||
self.HW_MTU = 262144 # 256KiB
|
||||
self.bitrate = 1_000_000_000 # 1Gbps
|
||||
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||
|
||||
self.server: Server | None = None
|
||||
self.spawned_interfaces: [WebsocketClientInterface] = []
|
||||
|
||||
# parse config
|
||||
ifconf = Interface.get_config_obj(configuration)
|
||||
self.name = ifconf.get("name")
|
||||
self.listen_ip = ifconf.get("listen_ip", None)
|
||||
self.listen_port = ifconf.get("listen_port", None)
|
||||
|
||||
# ensure listen ip is provided
|
||||
if self.listen_ip is None:
|
||||
msg = f"listen_ip is required for interface '{self.name}'"
|
||||
raise SystemError(msg)
|
||||
|
||||
# ensure listen port is provided
|
||||
if self.listen_port is None:
|
||||
msg = f"listen_port is required for interface '{self.name}'"
|
||||
raise SystemError(msg)
|
||||
|
||||
# convert listen port to int
|
||||
self.listen_port = int(self.listen_port)
|
||||
|
||||
# run websocket server
|
||||
thread = threading.Thread(target=self.serve)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
@property
|
||||
def clients(self):
|
||||
return len(self.spawned_interfaces)
|
||||
|
||||
# TODO docs
|
||||
def received_announce(self, from_spawned=False):
|
||||
if from_spawned:
|
||||
self.ia_freq_deque.append(time.time())
|
||||
|
||||
# TODO docs
|
||||
def sent_announce(self, from_spawned=False):
|
||||
if from_spawned:
|
||||
self.oa_freq_deque.append(time.time())
|
||||
|
||||
# do nothing as the spawned child interface will take care of rx/tx
|
||||
def process_incoming(self, data):
|
||||
pass
|
||||
|
||||
# do nothing as the spawned child interface will take care of rx/tx
|
||||
def process_outgoing(self, data):
|
||||
pass
|
||||
|
||||
def serve(self):
|
||||
# handle new websocket client connections
|
||||
def on_websocket_client_connected(websocket: ServerConnection):
|
||||
# create new child interface
|
||||
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
|
||||
spawned_interface = WebsocketClientInterface(
|
||||
self.owner,
|
||||
{
|
||||
"name": f"Client on {self.name}",
|
||||
"target_host": websocket.remote_address[0],
|
||||
"target_port": str(websocket.remote_address[1]),
|
||||
},
|
||||
websocket=websocket,
|
||||
)
|
||||
|
||||
# configure child interface
|
||||
spawned_interface.IN = self.IN
|
||||
spawned_interface.OUT = self.OUT
|
||||
spawned_interface.HW_MTU = self.HW_MTU
|
||||
spawned_interface.bitrate = self.bitrate
|
||||
spawned_interface.mode = self.mode
|
||||
spawned_interface.parent_interface = self
|
||||
spawned_interface.online = True
|
||||
|
||||
# TODO implement?
|
||||
spawned_interface.announce_rate_target = None
|
||||
spawned_interface.announce_rate_grace = None
|
||||
spawned_interface.announce_rate_penalty = None
|
||||
|
||||
# TODO ifac?
|
||||
# TODO announce rates?
|
||||
|
||||
# activate child interface
|
||||
RNS.log(
|
||||
f"Spawned new WebsocketClientInterface: {spawned_interface}",
|
||||
RNS.LOG_VERBOSE,
|
||||
)
|
||||
RNS.Transport.interfaces.append(spawned_interface)
|
||||
|
||||
# associate child interface with this interface
|
||||
while spawned_interface in self.spawned_interfaces:
|
||||
self.spawned_interfaces.remove(spawned_interface)
|
||||
self.spawned_interfaces.append(spawned_interface)
|
||||
|
||||
# run read loop
|
||||
spawned_interface.read_loop()
|
||||
|
||||
# client must have disconnected as the read loop finished, so forget the spawned interface
|
||||
self.spawned_interfaces.remove(spawned_interface)
|
||||
|
||||
# run websocket server
|
||||
try:
|
||||
RNS.log(f"Starting Websocket server for {self!s}...", RNS.LOG_DEBUG)
|
||||
with serve(
|
||||
on_websocket_client_connected,
|
||||
self.listen_ip,
|
||||
self.listen_port,
|
||||
compression=None,
|
||||
) as server:
|
||||
self.online = True
|
||||
self.server = server
|
||||
server.serve_forever()
|
||||
except Exception as e:
|
||||
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
|
||||
|
||||
# websocket server is no longer running, let's restart it
|
||||
self.online = False
|
||||
RNS.log(f"Websocket server stopped for {self!s}...", RNS.LOG_DEBUG)
|
||||
time.sleep(self.RESTART_DELAY_SECONDS)
|
||||
self.serve()
|
||||
|
||||
def detach(self):
|
||||
# mark as offline
|
||||
self.online = False
|
||||
|
||||
# stop websocket server
|
||||
if self.server is not None:
|
||||
self.server.shutdown()
|
||||
|
||||
# mark as detached
|
||||
self.detached = True
|
||||
|
||||
|
||||
# set interface class RNS should use when importing this external interface
|
||||
interface_class = WebsocketServerInterface
|
||||
1
meshchatx/src/backend/interfaces/__init__.py
Normal file
1
meshchatx/src/backend/interfaces/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared transport interfaces for MeshChatX."""
|
||||
25
meshchatx/src/backend/lxmf_message_fields.py
Normal file
25
meshchatx/src/backend/lxmf_message_fields.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# helper class for passing around an lxmf audio field
|
||||
class LxmfAudioField:
|
||||
def __init__(self, audio_mode: int, audio_bytes: bytes):
|
||||
self.audio_mode = audio_mode
|
||||
self.audio_bytes = audio_bytes
|
||||
|
||||
|
||||
# helper class for passing around an lxmf image field
|
||||
class LxmfImageField:
|
||||
def __init__(self, image_type: str, image_bytes: bytes):
|
||||
self.image_type = image_type
|
||||
self.image_bytes = image_bytes
|
||||
|
||||
|
||||
# helper class for passing around an lxmf file attachment
|
||||
class LxmfFileAttachment:
|
||||
def __init__(self, file_name: str, file_bytes: bytes):
|
||||
self.file_name = file_name
|
||||
self.file_bytes = file_bytes
|
||||
|
||||
|
||||
# helper class for passing around an lxmf file attachments field
|
||||
class LxmfFileAttachmentsField:
|
||||
def __init__(self, file_attachments: list[LxmfFileAttachment]):
|
||||
self.file_attachments = file_attachments
|
||||
249
meshchatx/src/backend/map_manager.py
Normal file
249
meshchatx/src/backend/map_manager.py
Normal file
@@ -0,0 +1,249 @@
|
||||
import math
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
import RNS
|
||||
|
||||
|
||||
class MapManager:
|
||||
def __init__(self, config_manager, storage_dir):
|
||||
self.config = config_manager
|
||||
self.storage_dir = storage_dir
|
||||
self._local = threading.local()
|
||||
self._metadata_cache = None
|
||||
self._export_progress = {}
|
||||
|
||||
def get_connection(self, path):
|
||||
if not hasattr(self._local, "connections"):
|
||||
self._local.connections = {}
|
||||
|
||||
if path not in self._local.connections:
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
conn = sqlite3.connect(path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
self._local.connections[path] = conn
|
||||
|
||||
return self._local.connections[path]
|
||||
|
||||
def get_offline_path(self):
|
||||
path = self.config.map_offline_path.get()
|
||||
if path:
|
||||
return path
|
||||
|
||||
# Fallback to default if not set but file exists
|
||||
default_path = os.path.join(self.storage_dir, "offline_map.mbtiles")
|
||||
if os.path.exists(default_path):
|
||||
return default_path
|
||||
|
||||
return None
|
||||
|
||||
def get_mbtiles_dir(self):
|
||||
dir_path = self.config.map_mbtiles_dir.get()
|
||||
if dir_path and os.path.isdir(dir_path):
|
||||
return dir_path
|
||||
return self.storage_dir
|
||||
|
||||
def list_mbtiles(self):
|
||||
mbtiles_dir = self.get_mbtiles_dir()
|
||||
files = []
|
||||
if os.path.exists(mbtiles_dir):
|
||||
for f in os.listdir(mbtiles_dir):
|
||||
if f.endswith(".mbtiles"):
|
||||
full_path = os.path.join(mbtiles_dir, f)
|
||||
stats = os.stat(full_path)
|
||||
files.append({
|
||||
"name": f,
|
||||
"path": full_path,
|
||||
"size": stats.st_size,
|
||||
"mtime": stats.st_mtime,
|
||||
"is_active": full_path == self.get_offline_path(),
|
||||
})
|
||||
return sorted(files, key=lambda x: x["mtime"], reverse=True)
|
||||
|
||||
def delete_mbtiles(self, filename):
|
||||
mbtiles_dir = self.get_mbtiles_dir()
|
||||
file_path = os.path.join(mbtiles_dir, filename)
|
||||
if os.path.exists(file_path) and file_path.endswith(".mbtiles"):
|
||||
if file_path == self.get_offline_path():
|
||||
self.config.map_offline_path.set(None)
|
||||
self.config.map_offline_enabled.set(False)
|
||||
os.remove(file_path)
|
||||
self._metadata_cache = None
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_metadata(self):
|
||||
path = self.get_offline_path()
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
|
||||
if self._metadata_cache and self._metadata_cache.get("path") == path:
|
||||
return self._metadata_cache
|
||||
|
||||
conn = self.get_connection(path)
|
||||
if not conn:
|
||||
return None
|
||||
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name, value FROM metadata")
|
||||
rows = cursor.fetchall()
|
||||
metadata = {row["name"]: row["value"] for row in rows}
|
||||
metadata["path"] = path
|
||||
|
||||
# Basic validation: ensure it's raster (format is not pbf)
|
||||
if metadata.get("format") == "pbf":
|
||||
RNS.log("MBTiles file is in vector (PBF) format, which is not supported.", RNS.LOG_ERROR)
|
||||
return None
|
||||
|
||||
self._metadata_cache = metadata
|
||||
return metadata
|
||||
except Exception as e:
|
||||
RNS.log(f"Error reading MBTiles metadata: {e}", RNS.LOG_ERROR)
|
||||
return None
|
||||
|
||||
def get_tile(self, z, x, y):
|
||||
path = self.get_offline_path()
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
|
||||
conn = self.get_connection(path)
|
||||
if not conn:
|
||||
return None
|
||||
|
||||
try:
|
||||
# MBTiles uses TMS tiling scheme (y is flipped)
|
||||
tms_y = (1 << z) - 1 - y
|
||||
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?",
|
||||
(z, x, tms_y),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return row["tile_data"]
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
RNS.log(f"Error reading MBTiles tile {z}/{x}/{y}: {e}", RNS.LOG_ERROR)
|
||||
return None
|
||||
|
||||
def start_export(self, export_id, bbox, min_zoom, max_zoom, name="Exported Map"):
|
||||
"""Start downloading tiles and creating an MBTiles file in a background thread."""
|
||||
thread = threading.Thread(
|
||||
target=self._run_export,
|
||||
args=(export_id, bbox, min_zoom, max_zoom, name),
|
||||
daemon=True,
|
||||
)
|
||||
self._export_progress[export_id] = {
|
||||
"status": "starting",
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
"current": 0,
|
||||
"start_time": time.time(),
|
||||
}
|
||||
thread.start()
|
||||
return export_id
|
||||
|
||||
def get_export_status(self, export_id):
|
||||
return self._export_progress.get(export_id)
|
||||
|
||||
def _run_export(self, export_id, bbox, min_zoom, max_zoom, name):
|
||||
# bbox: [min_lon, min_lat, max_lon, max_lat]
|
||||
min_lon, min_lat, max_lon, max_lat = bbox
|
||||
|
||||
# calculate total tiles
|
||||
total_tiles = 0
|
||||
zoom_levels = range(min_zoom, max_zoom + 1)
|
||||
for z in zoom_levels:
|
||||
x1, y1 = self._lonlat_to_tile(min_lon, max_lat, z)
|
||||
x2, y2 = self._lonlat_to_tile(max_lon, min_lat, z)
|
||||
total_tiles += (x2 - x1 + 1) * (y2 - y1 + 1)
|
||||
|
||||
self._export_progress[export_id]["total"] = total_tiles
|
||||
self._export_progress[export_id]["status"] = "downloading"
|
||||
|
||||
dest_path = os.path.join(self.storage_dir, f"export_{export_id}.mbtiles")
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(dest_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# create schema
|
||||
cursor.execute("CREATE TABLE metadata (name text, value text)")
|
||||
cursor.execute("CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)")
|
||||
cursor.execute("CREATE UNIQUE INDEX tile_index on tiles (zoom_level, tile_column, tile_row)")
|
||||
|
||||
# insert metadata
|
||||
metadata = [
|
||||
("name", name),
|
||||
("type", "baselayer"),
|
||||
("version", "1.1"),
|
||||
("description", f"Exported from MeshChatX on {time.ctime()}"),
|
||||
("format", "png"),
|
||||
("bounds", f"{min_lon},{min_lat},{max_lon},{max_lat}"),
|
||||
]
|
||||
cursor.executemany("INSERT INTO metadata VALUES (?, ?)", metadata)
|
||||
|
||||
current_count = 0
|
||||
for z in zoom_levels:
|
||||
x1, y1 = self._lonlat_to_tile(min_lon, max_lat, z)
|
||||
x2, y2 = self._lonlat_to_tile(max_lon, min_lat, z)
|
||||
|
||||
for x in range(x1, x2 + 1):
|
||||
for y in range(y1, y2 + 1):
|
||||
# check if we should stop (if we add a cancel mechanism)
|
||||
|
||||
# download tile
|
||||
tile_url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
try:
|
||||
# wait a bit to be nice to OSM
|
||||
time.sleep(0.1)
|
||||
|
||||
response = requests.get(tile_url, headers={"User-Agent": "MeshChatX/1.0 MapExporter"}, timeout=10)
|
||||
if response.status_code == 200:
|
||||
# MBTiles uses TMS (y flipped)
|
||||
tms_y = (1 << z) - 1 - y
|
||||
cursor.execute(
|
||||
"INSERT INTO tiles VALUES (?, ?, ?, ?)",
|
||||
(z, x, tms_y, response.content),
|
||||
)
|
||||
except Exception as e:
|
||||
RNS.log(f"Export failed to download tile {z}/{x}/{y}: {e}", RNS.LOG_ERROR)
|
||||
|
||||
current_count += 1
|
||||
self._export_progress[export_id]["current"] = current_count
|
||||
self._export_progress[export_id]["progress"] = int((current_count / total_tiles) * 100)
|
||||
|
||||
# commit after each zoom level
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
self._export_progress[export_id]["status"] = "completed"
|
||||
self._export_progress[export_id]["file_path"] = dest_path
|
||||
|
||||
except Exception as e:
|
||||
RNS.log(f"Map export failed: {e}", RNS.LOG_ERROR)
|
||||
self._export_progress[export_id]["status"] = "failed"
|
||||
self._export_progress[export_id]["error"] = str(e)
|
||||
if os.path.exists(dest_path):
|
||||
os.remove(dest_path)
|
||||
|
||||
def _lonlat_to_tile(self, lon, lat, zoom):
|
||||
lat_rad = math.radians(lat)
|
||||
n = 2.0 ** zoom
|
||||
x = int((lon + 180.0) / 360.0 * n)
|
||||
y = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n)
|
||||
return x, y
|
||||
|
||||
def close(self):
|
||||
if hasattr(self._local, "connections"):
|
||||
for conn in self._local.connections.values():
|
||||
conn.close()
|
||||
self._local.connections = {}
|
||||
self._metadata_cache = None
|
||||
66
meshchatx/src/backend/message_handler.py
Normal file
66
meshchatx/src/backend/message_handler.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from .database import Database
|
||||
|
||||
|
||||
class MessageHandler:
|
||||
def __init__(self, db: Database):
|
||||
self.db = db
|
||||
|
||||
def get_conversation_messages(self, local_hash, destination_hash, limit=100, offset=0, after_id=None, before_id=None):
|
||||
query = """
|
||||
SELECT * FROM lxmf_messages
|
||||
WHERE ((source_hash = ? AND destination_hash = ?)
|
||||
OR (destination_hash = ? AND source_hash = ?))
|
||||
"""
|
||||
params = [local_hash, destination_hash, local_hash, destination_hash]
|
||||
|
||||
if after_id:
|
||||
query += " AND id > ?"
|
||||
params.append(after_id)
|
||||
if before_id:
|
||||
query += " AND id < ?"
|
||||
params.append(before_id)
|
||||
|
||||
query += " ORDER BY id DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
return self.db.provider.fetchall(query, params)
|
||||
|
||||
def delete_conversation(self, local_hash, destination_hash):
|
||||
query = """
|
||||
DELETE FROM lxmf_messages
|
||||
WHERE ((source_hash = ? AND destination_hash = ?)
|
||||
OR (destination_hash = ? AND source_hash = ?))
|
||||
"""
|
||||
self.db.provider.execute(query, [local_hash, destination_hash, local_hash, destination_hash])
|
||||
|
||||
def search_messages(self, local_hash, search_term):
|
||||
like_term = f"%{search_term}%"
|
||||
query = """
|
||||
SELECT source_hash, destination_hash, MAX(timestamp) as max_ts
|
||||
FROM lxmf_messages
|
||||
WHERE (source_hash = ? OR destination_hash = ?)
|
||||
AND (title LIKE ? OR content LIKE ? OR source_hash LIKE ? OR destination_hash LIKE ?)
|
||||
GROUP BY source_hash, destination_hash
|
||||
"""
|
||||
params = [local_hash, local_hash, like_term, like_term, like_term, like_term]
|
||||
return self.db.provider.fetchall(query, params)
|
||||
|
||||
def get_conversations(self, local_hash):
|
||||
# Implementation moved from get_conversations DAO but with local_hash filter
|
||||
query = """
|
||||
SELECT m1.* FROM lxmf_messages m1
|
||||
JOIN (
|
||||
SELECT
|
||||
CASE WHEN source_hash = ? THEN destination_hash ELSE source_hash END as peer_hash,
|
||||
MAX(timestamp) as max_ts
|
||||
FROM lxmf_messages
|
||||
WHERE source_hash = ? OR destination_hash = ?
|
||||
GROUP BY peer_hash
|
||||
) m2 ON (CASE WHEN m1.source_hash = ? THEN m1.destination_hash ELSE m1.source_hash END = m2.peer_hash
|
||||
AND m1.timestamp = m2.max_ts)
|
||||
WHERE m1.source_hash = ? OR m1.destination_hash = ?
|
||||
ORDER BY m1.timestamp DESC
|
||||
"""
|
||||
params = [local_hash, local_hash, local_hash, local_hash, local_hash, local_hash]
|
||||
return self.db.provider.fetchall(query, params)
|
||||
|
||||
421
meshchatx/src/backend/rncp_handler.py
Normal file
421
meshchatx/src/backend/rncp_handler.py
Normal file
@@ -0,0 +1,421 @@
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import RNS
|
||||
|
||||
|
||||
class RNCPHandler:
|
||||
APP_NAME = "rncp"
|
||||
REQ_FETCH_NOT_ALLOWED = 0xF0
|
||||
|
||||
def __init__(self, reticulum_instance, identity, storage_dir):
|
||||
self.reticulum = reticulum_instance
|
||||
self.identity = identity
|
||||
self.storage_dir = storage_dir
|
||||
self.active_transfers = {}
|
||||
self.receive_destination = None
|
||||
self.fetch_jail = None
|
||||
self.fetch_auto_compress = True
|
||||
self.allow_overwrite_on_receive = False
|
||||
self.allowed_identity_hashes = []
|
||||
|
||||
def setup_receive_destination(self, allowed_hashes=None, fetch_allowed=False, fetch_jail=None, allow_overwrite=False):
|
||||
if allowed_hashes:
|
||||
self.allowed_identity_hashes = [bytes.fromhex(h) if isinstance(h, str) else h for h in allowed_hashes]
|
||||
|
||||
self.fetch_jail = fetch_jail
|
||||
self.allow_overwrite_on_receive = allow_overwrite
|
||||
|
||||
identity_path = os.path.join(RNS.Reticulum.identitypath, self.APP_NAME)
|
||||
if os.path.isfile(identity_path):
|
||||
receive_identity = RNS.Identity.from_file(identity_path)
|
||||
else:
|
||||
receive_identity = RNS.Identity()
|
||||
receive_identity.to_file(identity_path)
|
||||
|
||||
self.receive_destination = RNS.Destination(
|
||||
receive_identity,
|
||||
RNS.Destination.IN,
|
||||
RNS.Destination.SINGLE,
|
||||
self.APP_NAME,
|
||||
"receive",
|
||||
)
|
||||
|
||||
self.receive_destination.set_link_established_callback(self._client_link_established)
|
||||
|
||||
if fetch_allowed:
|
||||
self.receive_destination.register_request_handler(
|
||||
"fetch_file",
|
||||
response_generator=self._fetch_request,
|
||||
allow=RNS.Destination.ALLOW_LIST,
|
||||
allowed_list=self.allowed_identity_hashes,
|
||||
)
|
||||
|
||||
return self.receive_destination.hash.hex()
|
||||
|
||||
def _client_link_established(self, link):
|
||||
link.set_remote_identified_callback(self._receive_sender_identified)
|
||||
link.set_resource_strategy(RNS.Link.ACCEPT_APP)
|
||||
link.set_resource_callback(self._receive_resource_callback)
|
||||
link.set_resource_started_callback(self._receive_resource_started)
|
||||
link.set_resource_concluded_callback(self._receive_resource_concluded)
|
||||
|
||||
def _receive_sender_identified(self, link, identity):
|
||||
if identity.hash not in self.allowed_identity_hashes:
|
||||
link.teardown()
|
||||
|
||||
def _receive_resource_callback(self, resource):
|
||||
sender_identity = resource.link.get_remote_identity()
|
||||
if sender_identity and sender_identity.hash in self.allowed_identity_hashes:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _receive_resource_started(self, resource):
|
||||
transfer_id = resource.hash.hex()
|
||||
self.active_transfers[transfer_id] = {
|
||||
"resource": resource,
|
||||
"status": "receiving",
|
||||
"started_at": time.time(),
|
||||
}
|
||||
|
||||
def _receive_resource_concluded(self, resource):
|
||||
transfer_id = resource.hash.hex()
|
||||
if resource.status == RNS.Resource.COMPLETE:
|
||||
if resource.metadata:
|
||||
try:
|
||||
filename = os.path.basename(resource.metadata["name"].decode("utf-8"))
|
||||
save_dir = os.path.join(self.storage_dir, "rncp_received")
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
saved_filename = os.path.join(save_dir, filename)
|
||||
counter = 0
|
||||
|
||||
if self.allow_overwrite_on_receive:
|
||||
if os.path.isfile(saved_filename):
|
||||
try:
|
||||
os.unlink(saved_filename)
|
||||
except OSError:
|
||||
# Failed to delete existing file, which is fine,
|
||||
# we'll just fall through to the naming loop
|
||||
pass
|
||||
|
||||
while os.path.isfile(saved_filename):
|
||||
counter += 1
|
||||
base, ext = os.path.splitext(filename)
|
||||
saved_filename = os.path.join(save_dir, f"{base}.{counter}{ext}")
|
||||
|
||||
shutil.move(resource.data.name, saved_filename)
|
||||
|
||||
if transfer_id in self.active_transfers:
|
||||
self.active_transfers[transfer_id]["status"] = "completed"
|
||||
self.active_transfers[transfer_id]["saved_path"] = saved_filename
|
||||
self.active_transfers[transfer_id]["filename"] = filename
|
||||
except Exception as e:
|
||||
if transfer_id in self.active_transfers:
|
||||
self.active_transfers[transfer_id]["status"] = "error"
|
||||
self.active_transfers[transfer_id]["error"] = str(e)
|
||||
elif transfer_id in self.active_transfers:
|
||||
self.active_transfers[transfer_id]["status"] = "failed"
|
||||
|
||||
def _fetch_request(self, path, data, request_id, link_id, remote_identity, requested_at):
|
||||
if self.fetch_jail:
|
||||
if data.startswith(self.fetch_jail + "/"):
|
||||
data = data.replace(self.fetch_jail + "/", "")
|
||||
file_path = os.path.abspath(os.path.expanduser(f"{self.fetch_jail}/{data}"))
|
||||
if not file_path.startswith(self.fetch_jail + "/"):
|
||||
return self.REQ_FETCH_NOT_ALLOWED
|
||||
else:
|
||||
file_path = os.path.abspath(os.path.expanduser(data))
|
||||
|
||||
target_link = None
|
||||
for link in RNS.Transport.active_links:
|
||||
if link.link_id == link_id:
|
||||
target_link = link
|
||||
break
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
return False
|
||||
|
||||
if target_link:
|
||||
try:
|
||||
metadata = {"name": os.path.basename(file_path).encode("utf-8")}
|
||||
RNS.Resource(
|
||||
open(file_path, "rb"),
|
||||
target_link,
|
||||
metadata=metadata,
|
||||
auto_compress=self.fetch_auto_compress,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return None
|
||||
|
||||
async def send_file(
|
||||
self,
|
||||
destination_hash: bytes,
|
||||
file_path: str,
|
||||
timeout: float = RNS.Transport.PATH_REQUEST_TIMEOUT,
|
||||
on_progress: Callable[[float], None] | None = None,
|
||||
no_compress: bool = False,
|
||||
):
|
||||
file_path = os.path.expanduser(file_path)
|
||||
if not os.path.isfile(file_path):
|
||||
msg = f"File not found: {file_path}"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
if not RNS.Transport.has_path(destination_hash):
|
||||
RNS.Transport.request_path(destination_hash)
|
||||
|
||||
timeout_after = time.time() + timeout
|
||||
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if not RNS.Transport.has_path(destination_hash):
|
||||
msg = "Path not found to destination"
|
||||
raise TimeoutError(msg)
|
||||
|
||||
receiver_identity = RNS.Identity.recall(destination_hash)
|
||||
receiver_destination = RNS.Destination(
|
||||
receiver_identity,
|
||||
RNS.Destination.OUT,
|
||||
RNS.Destination.SINGLE,
|
||||
self.APP_NAME,
|
||||
"receive",
|
||||
)
|
||||
|
||||
link = RNS.Link(receiver_destination)
|
||||
timeout_after = time.time() + timeout
|
||||
while link.status != RNS.Link.ACTIVE and time.time() < timeout_after:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if link.status != RNS.Link.ACTIVE:
|
||||
msg = "Could not establish link to destination"
|
||||
raise TimeoutError(msg)
|
||||
|
||||
link.identify(self.identity)
|
||||
|
||||
auto_compress = not no_compress
|
||||
metadata = {"name": os.path.basename(file_path).encode("utf-8")}
|
||||
|
||||
def progress_callback(resource):
|
||||
if on_progress:
|
||||
progress = resource.get_progress()
|
||||
on_progress(progress)
|
||||
|
||||
resource = RNS.Resource(
|
||||
open(file_path, "rb"),
|
||||
link,
|
||||
metadata=metadata,
|
||||
callback=progress_callback,
|
||||
progress_callback=progress_callback,
|
||||
auto_compress=auto_compress,
|
||||
)
|
||||
|
||||
transfer_id = resource.hash.hex()
|
||||
self.active_transfers[transfer_id] = {
|
||||
"resource": resource,
|
||||
"status": "sending",
|
||||
"started_at": time.time(),
|
||||
"file_path": file_path,
|
||||
}
|
||||
|
||||
while resource.status < RNS.Resource.COMPLETE:
|
||||
await asyncio.sleep(0.1)
|
||||
if resource.status > RNS.Resource.COMPLETE:
|
||||
msg = "File was not accepted by destination"
|
||||
raise Exception(msg)
|
||||
|
||||
if resource.status == RNS.Resource.COMPLETE:
|
||||
if transfer_id in self.active_transfers:
|
||||
self.active_transfers[transfer_id]["status"] = "completed"
|
||||
link.teardown()
|
||||
return {
|
||||
"transfer_id": transfer_id,
|
||||
"status": "completed",
|
||||
"file_path": file_path,
|
||||
}
|
||||
if transfer_id in self.active_transfers:
|
||||
self.active_transfers[transfer_id]["status"] = "failed"
|
||||
link.teardown()
|
||||
msg = "Transfer failed"
|
||||
raise Exception(msg)
|
||||
|
||||
async def fetch_file(
|
||||
self,
|
||||
destination_hash: bytes,
|
||||
file_path: str,
|
||||
timeout: float = RNS.Transport.PATH_REQUEST_TIMEOUT,
|
||||
on_progress: Callable[[float], None] | None = None,
|
||||
save_path: str | None = None,
|
||||
allow_overwrite: bool = False,
|
||||
):
|
||||
if not RNS.Transport.has_path(destination_hash):
|
||||
RNS.Transport.request_path(destination_hash)
|
||||
|
||||
timeout_after = time.time() + timeout
|
||||
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if not RNS.Transport.has_path(destination_hash):
|
||||
msg = "Path not found to destination"
|
||||
raise TimeoutError(msg)
|
||||
|
||||
listener_identity = RNS.Identity.recall(destination_hash)
|
||||
listener_destination = RNS.Destination(
|
||||
listener_identity,
|
||||
RNS.Destination.OUT,
|
||||
RNS.Destination.SINGLE,
|
||||
self.APP_NAME,
|
||||
"receive",
|
||||
)
|
||||
|
||||
link = RNS.Link(listener_destination)
|
||||
timeout_after = time.time() + timeout
|
||||
while link.status != RNS.Link.ACTIVE and time.time() < timeout_after:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if link.status != RNS.Link.ACTIVE:
|
||||
msg = "Could not establish link to destination"
|
||||
raise TimeoutError(msg)
|
||||
|
||||
link.identify(self.identity)
|
||||
|
||||
request_resolved = False
|
||||
request_status = "unknown"
|
||||
resource_resolved = False
|
||||
resource_status = "unrequested"
|
||||
current_resource = None
|
||||
|
||||
def request_response(request_receipt):
|
||||
nonlocal request_resolved, request_status
|
||||
if not request_receipt.response:
|
||||
request_status = "not_found"
|
||||
elif request_receipt.response is None:
|
||||
request_status = "remote_error"
|
||||
elif request_receipt.response == self.REQ_FETCH_NOT_ALLOWED:
|
||||
request_status = "fetch_not_allowed"
|
||||
else:
|
||||
request_status = "found"
|
||||
request_resolved = True
|
||||
|
||||
def request_failed(request_receipt):
|
||||
nonlocal request_resolved, request_status
|
||||
request_status = "unknown"
|
||||
request_resolved = True
|
||||
|
||||
def fetch_resource_started(resource):
|
||||
nonlocal resource_status, current_resource
|
||||
current_resource = resource
|
||||
|
||||
def progress_callback(resource):
|
||||
if on_progress:
|
||||
progress = resource.get_progress()
|
||||
on_progress(progress)
|
||||
|
||||
current_resource.progress_callback(progress_callback)
|
||||
resource_status = "started"
|
||||
|
||||
saved_filename = None
|
||||
|
||||
def fetch_resource_concluded(resource):
|
||||
nonlocal resource_resolved, resource_status, saved_filename
|
||||
if resource.status == RNS.Resource.COMPLETE:
|
||||
if resource.metadata:
|
||||
try:
|
||||
filename = os.path.basename(resource.metadata["name"].decode("utf-8"))
|
||||
if save_path:
|
||||
save_dir = os.path.abspath(os.path.expanduser(save_path))
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
saved_filename = os.path.join(save_dir, filename)
|
||||
else:
|
||||
saved_filename = filename
|
||||
|
||||
counter = 0
|
||||
if allow_overwrite:
|
||||
if os.path.isfile(saved_filename):
|
||||
try:
|
||||
os.unlink(saved_filename)
|
||||
except OSError:
|
||||
# Failed to delete existing file, which is fine,
|
||||
# we'll just fall through to the naming loop
|
||||
pass
|
||||
|
||||
while os.path.isfile(saved_filename):
|
||||
counter += 1
|
||||
base, ext = os.path.splitext(filename)
|
||||
saved_filename = os.path.join(
|
||||
os.path.dirname(saved_filename) if save_path else ".",
|
||||
f"{base}.{counter}{ext}",
|
||||
)
|
||||
|
||||
shutil.move(resource.data.name, saved_filename)
|
||||
resource_status = "completed"
|
||||
except Exception as e:
|
||||
resource_status = "error"
|
||||
raise e
|
||||
else:
|
||||
resource_status = "error"
|
||||
else:
|
||||
resource_status = "failed"
|
||||
|
||||
resource_resolved = True
|
||||
|
||||
link.set_resource_strategy(RNS.Link.ACCEPT_ALL)
|
||||
link.set_resource_started_callback(fetch_resource_started)
|
||||
link.set_resource_concluded_callback(fetch_resource_concluded)
|
||||
link.request("fetch_file", data=file_path, response_callback=request_response, failed_callback=request_failed)
|
||||
|
||||
while not request_resolved:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if request_status == "fetch_not_allowed":
|
||||
link.teardown()
|
||||
msg = "Fetch request not allowed by remote"
|
||||
raise PermissionError(msg)
|
||||
if request_status == "not_found":
|
||||
link.teardown()
|
||||
msg = f"File not found on remote: {file_path}"
|
||||
raise FileNotFoundError(msg)
|
||||
if request_status == "remote_error":
|
||||
link.teardown()
|
||||
msg = "Remote error during fetch request"
|
||||
raise Exception(msg)
|
||||
if request_status == "unknown":
|
||||
link.teardown()
|
||||
msg = "Unknown error during fetch request"
|
||||
raise Exception(msg)
|
||||
|
||||
while not resource_resolved:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if resource_status == "completed":
|
||||
link.teardown()
|
||||
return {
|
||||
"status": "completed",
|
||||
"file_path": saved_filename,
|
||||
}
|
||||
link.teardown()
|
||||
msg = f"Transfer failed: {resource_status}"
|
||||
raise Exception(msg)
|
||||
|
||||
def get_transfer_status(self, transfer_id: str):
|
||||
if transfer_id in self.active_transfers:
|
||||
transfer = self.active_transfers[transfer_id]
|
||||
resource = transfer.get("resource")
|
||||
if resource:
|
||||
progress = resource.get_progress()
|
||||
return {
|
||||
"transfer_id": transfer_id,
|
||||
"status": transfer["status"],
|
||||
"progress": progress,
|
||||
"file_path": transfer.get("file_path"),
|
||||
"saved_path": transfer.get("saved_path"),
|
||||
"filename": transfer.get("filename"),
|
||||
"error": transfer.get("error"),
|
||||
}
|
||||
return None
|
||||
|
||||
137
meshchatx/src/backend/rnprobe_handler.py
Normal file
137
meshchatx/src/backend/rnprobe_handler.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
|
||||
import RNS
|
||||
|
||||
|
||||
class RNProbeHandler:
|
||||
DEFAULT_PROBE_SIZE = 16
|
||||
DEFAULT_TIMEOUT = 12
|
||||
|
||||
def __init__(self, reticulum_instance, identity):
|
||||
self.reticulum = reticulum_instance
|
||||
self.identity = identity
|
||||
|
||||
async def probe_destination(
|
||||
self,
|
||||
destination_hash: bytes,
|
||||
full_name: str,
|
||||
size: int = DEFAULT_PROBE_SIZE,
|
||||
timeout: float | None = None,
|
||||
wait: float = 0,
|
||||
probes: int = 1,
|
||||
):
|
||||
try:
|
||||
app_name, aspects = RNS.Destination.app_and_aspects_from_name(full_name)
|
||||
except Exception as e:
|
||||
msg = f"Invalid destination name: {e}"
|
||||
raise ValueError(msg)
|
||||
|
||||
if not RNS.Transport.has_path(destination_hash):
|
||||
RNS.Transport.request_path(destination_hash)
|
||||
|
||||
timeout_after = time.time() + (timeout or self.DEFAULT_TIMEOUT + self.reticulum.get_first_hop_timeout(destination_hash))
|
||||
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if not RNS.Transport.has_path(destination_hash):
|
||||
msg = "Path request timed out"
|
||||
raise TimeoutError(msg)
|
||||
|
||||
server_identity = RNS.Identity.recall(destination_hash)
|
||||
request_destination = RNS.Destination(
|
||||
server_identity,
|
||||
RNS.Destination.OUT,
|
||||
RNS.Destination.SINGLE,
|
||||
app_name,
|
||||
*aspects,
|
||||
)
|
||||
|
||||
results = []
|
||||
sent = 0
|
||||
|
||||
while probes > 0:
|
||||
if sent > 0:
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
try:
|
||||
probe = RNS.Packet(request_destination, os.urandom(size))
|
||||
probe.pack()
|
||||
except OSError:
|
||||
msg = f"Probe packet size of {len(probe.raw)} bytes exceeds MTU of {RNS.Reticulum.MTU} bytes"
|
||||
raise ValueError(msg)
|
||||
|
||||
receipt = probe.send()
|
||||
sent += 1
|
||||
|
||||
next_hop = self.reticulum.get_next_hop(destination_hash)
|
||||
via_str = f" via {RNS.prettyhexrep(next_hop)}" if next_hop else ""
|
||||
if_name = self.reticulum.get_next_hop_if_name(destination_hash)
|
||||
if_str = f" on {if_name}" if if_name and if_name != "None" else ""
|
||||
|
||||
timeout_after = time.time() + (timeout or self.DEFAULT_TIMEOUT + self.reticulum.get_first_hop_timeout(destination_hash))
|
||||
while receipt.status == RNS.PacketReceipt.SENT and time.time() < timeout_after:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
result: dict = {
|
||||
"probe_number": sent,
|
||||
"size": size,
|
||||
"destination": RNS.prettyhexrep(destination_hash),
|
||||
"via": via_str,
|
||||
"interface": if_str,
|
||||
"status": "timeout",
|
||||
}
|
||||
|
||||
if time.time() > timeout_after:
|
||||
result["status"] = "timeout"
|
||||
elif receipt.status == RNS.PacketReceipt.DELIVERED:
|
||||
hops = RNS.Transport.hops_to(destination_hash)
|
||||
rtt = receipt.get_rtt()
|
||||
|
||||
if rtt >= 1:
|
||||
rtt_str = f"{round(rtt, 3)} seconds"
|
||||
else:
|
||||
rtt_str = f"{round(rtt * 1000, 3)} milliseconds"
|
||||
|
||||
reception_stats = {}
|
||||
if self.reticulum.is_connected_to_shared_instance:
|
||||
reception_rssi = self.reticulum.get_packet_rssi(receipt.proof_packet.packet_hash)
|
||||
reception_snr = self.reticulum.get_packet_snr(receipt.proof_packet.packet_hash)
|
||||
reception_q = self.reticulum.get_packet_q(receipt.proof_packet.packet_hash)
|
||||
|
||||
if reception_rssi is not None:
|
||||
reception_stats["rssi"] = reception_rssi
|
||||
if reception_snr is not None:
|
||||
reception_stats["snr"] = reception_snr
|
||||
if reception_q is not None:
|
||||
reception_stats["quality"] = reception_q
|
||||
elif receipt.proof_packet:
|
||||
if receipt.proof_packet.rssi is not None:
|
||||
reception_stats["rssi"] = receipt.proof_packet.rssi
|
||||
if receipt.proof_packet.snr is not None:
|
||||
reception_stats["snr"] = receipt.proof_packet.snr
|
||||
|
||||
result.update(
|
||||
{
|
||||
"status": "delivered",
|
||||
"hops": hops,
|
||||
"rtt": rtt,
|
||||
"rtt_string": rtt_str,
|
||||
"reception_stats": reception_stats,
|
||||
},
|
||||
)
|
||||
else:
|
||||
result["status"] = "failed"
|
||||
|
||||
results.append(result)
|
||||
probes -= 1
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"sent": sent,
|
||||
"delivered": sum(1 for r in results if r["status"] == "delivered"),
|
||||
"timeouts": sum(1 for r in results if r["status"] == "timeout"),
|
||||
"failed": sum(1 for r in results if r["status"] == "failed"),
|
||||
}
|
||||
|
||||
184
meshchatx/src/backend/rnstatus_handler.py
Normal file
184
meshchatx/src/backend/rnstatus_handler.py
Normal file
@@ -0,0 +1,184 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
def size_str(num, suffix="B"):
|
||||
units = ["", "K", "M", "G", "T", "P", "E", "Z"]
|
||||
last_unit = "Y"
|
||||
|
||||
if suffix == "b":
|
||||
num *= 8
|
||||
units = ["", "K", "M", "G", "T", "P", "E", "Z"]
|
||||
last_unit = "Y"
|
||||
|
||||
for unit in units:
|
||||
if abs(num) < 1000.0:
|
||||
if unit == "":
|
||||
return f"{num:.0f} {unit}{suffix}"
|
||||
return f"{num:.2f} {unit}{suffix}"
|
||||
num /= 1000.0
|
||||
|
||||
return f"{num:.2f}{last_unit}{suffix}"
|
||||
|
||||
|
||||
class RNStatusHandler:
|
||||
def __init__(self, reticulum_instance):
|
||||
self.reticulum = reticulum_instance
|
||||
|
||||
def get_status(self, include_link_stats: bool = False, sorting: str | None = None, sort_reverse: bool = False):
|
||||
stats = None
|
||||
link_count = None
|
||||
|
||||
try:
|
||||
if include_link_stats:
|
||||
link_count = self.reticulum.get_link_count()
|
||||
except Exception as e:
|
||||
# We can't do much here if the reticulum instance fails
|
||||
print(f"Failed to get link count: {e}")
|
||||
|
||||
try:
|
||||
stats = self.reticulum.get_interface_stats()
|
||||
except Exception as e:
|
||||
# We can't do much here if the reticulum instance fails
|
||||
print(f"Failed to get interface stats: {e}")
|
||||
|
||||
if stats is None:
|
||||
return {
|
||||
"interfaces": [],
|
||||
"link_count": link_count,
|
||||
}
|
||||
|
||||
interfaces = stats.get("interfaces", [])
|
||||
|
||||
if sorting and isinstance(sorting, str):
|
||||
sorting = sorting.lower()
|
||||
if sorting in ("rate", "bitrate"):
|
||||
interfaces.sort(key=lambda i: i.get("bitrate", 0) or 0, reverse=sort_reverse)
|
||||
elif sorting == "rx":
|
||||
interfaces.sort(key=lambda i: i.get("rxb", 0) or 0, reverse=sort_reverse)
|
||||
elif sorting == "tx":
|
||||
interfaces.sort(key=lambda i: i.get("txb", 0) or 0, reverse=sort_reverse)
|
||||
elif sorting == "rxs":
|
||||
interfaces.sort(key=lambda i: i.get("rxs", 0) or 0, reverse=sort_reverse)
|
||||
elif sorting == "txs":
|
||||
interfaces.sort(key=lambda i: i.get("txs", 0) or 0, reverse=sort_reverse)
|
||||
elif sorting == "traffic":
|
||||
interfaces.sort(
|
||||
key=lambda i: (i.get("rxb", 0) or 0) + (i.get("txb", 0) or 0),
|
||||
reverse=sort_reverse,
|
||||
)
|
||||
elif sorting in ("announces", "announce"):
|
||||
interfaces.sort(
|
||||
key=lambda i: (i.get("incoming_announce_frequency", 0) or 0)
|
||||
+ (i.get("outgoing_announce_frequency", 0) or 0),
|
||||
reverse=sort_reverse,
|
||||
)
|
||||
elif sorting == "arx":
|
||||
interfaces.sort(
|
||||
key=lambda i: i.get("incoming_announce_frequency", 0) or 0,
|
||||
reverse=sort_reverse,
|
||||
)
|
||||
elif sorting == "atx":
|
||||
interfaces.sort(
|
||||
key=lambda i: i.get("outgoing_announce_frequency", 0) or 0,
|
||||
reverse=sort_reverse,
|
||||
)
|
||||
elif sorting == "held":
|
||||
interfaces.sort(key=lambda i: i.get("held_announces", 0) or 0, reverse=sort_reverse)
|
||||
|
||||
formatted_interfaces = []
|
||||
for ifstat in interfaces:
|
||||
name = ifstat.get("name", "")
|
||||
|
||||
if name.startswith("LocalInterface[") or name.startswith("TCPInterface[Client") or name.startswith("BackboneInterface[Client on"):
|
||||
continue
|
||||
|
||||
formatted_if: dict[str, Any] = {
|
||||
"name": name,
|
||||
"status": "Up" if ifstat.get("status") else "Down",
|
||||
}
|
||||
|
||||
mode = ifstat.get("mode")
|
||||
if mode == 1:
|
||||
formatted_if["mode"] = "Access Point"
|
||||
elif mode == 2:
|
||||
formatted_if["mode"] = "Point-to-Point"
|
||||
elif mode == 3:
|
||||
formatted_if["mode"] = "Roaming"
|
||||
elif mode == 4:
|
||||
formatted_if["mode"] = "Boundary"
|
||||
elif mode == 5:
|
||||
formatted_if["mode"] = "Gateway"
|
||||
else:
|
||||
formatted_if["mode"] = "Full"
|
||||
|
||||
if "bitrate" in ifstat and ifstat["bitrate"] is not None:
|
||||
formatted_if["bitrate"] = size_str(ifstat["bitrate"], "b") + "ps"
|
||||
|
||||
if "rxb" in ifstat:
|
||||
formatted_if["rx_bytes"] = ifstat["rxb"]
|
||||
formatted_if["rx_bytes_str"] = size_str(ifstat["rxb"])
|
||||
if "txb" in ifstat:
|
||||
formatted_if["tx_bytes"] = ifstat["txb"]
|
||||
formatted_if["tx_bytes_str"] = size_str(ifstat["txb"])
|
||||
if "rxs" in ifstat:
|
||||
formatted_if["rx_packets"] = ifstat["rxs"]
|
||||
if "txs" in ifstat:
|
||||
formatted_if["tx_packets"] = ifstat["txs"]
|
||||
|
||||
if "clients" in ifstat and ifstat["clients"] is not None:
|
||||
formatted_if["clients"] = ifstat["clients"]
|
||||
|
||||
if "noise_floor" in ifstat and ifstat["noise_floor"] is not None:
|
||||
formatted_if["noise_floor"] = f"{ifstat['noise_floor']} dBm"
|
||||
|
||||
if "interference" in ifstat and ifstat["interference"] is not None:
|
||||
formatted_if["interference"] = f"{ifstat['interference']} dBm"
|
||||
|
||||
if "cpu_load" in ifstat and ifstat["cpu_load"] is not None:
|
||||
formatted_if["cpu_load"] = f"{ifstat['cpu_load']}%"
|
||||
|
||||
if "cpu_temp" in ifstat and ifstat["cpu_temp"] is not None:
|
||||
formatted_if["cpu_temp"] = f"{ifstat['cpu_temp']}°C"
|
||||
|
||||
if "mem_load" in ifstat and ifstat["mem_load"] is not None:
|
||||
formatted_if["mem_load"] = f"{ifstat['mem_load']}%"
|
||||
|
||||
if "battery_percent" in ifstat and ifstat["battery_percent"] is not None:
|
||||
formatted_if["battery_percent"] = ifstat["battery_percent"]
|
||||
if "battery_state" in ifstat:
|
||||
formatted_if["battery_state"] = ifstat["battery_state"]
|
||||
|
||||
if "airtime_short" in ifstat and "airtime_long" in ifstat:
|
||||
formatted_if["airtime"] = {
|
||||
"short": ifstat["airtime_short"],
|
||||
"long": ifstat["airtime_long"],
|
||||
}
|
||||
|
||||
if "channel_load_short" in ifstat and "channel_load_long" in ifstat:
|
||||
formatted_if["channel_load"] = {
|
||||
"short": ifstat["channel_load_short"],
|
||||
"long": ifstat["channel_load_long"],
|
||||
}
|
||||
|
||||
if "peers" in ifstat and ifstat["peers"] is not None:
|
||||
formatted_if["peers"] = ifstat["peers"]
|
||||
|
||||
if "incoming_announce_frequency" in ifstat:
|
||||
formatted_if["incoming_announce_frequency"] = ifstat["incoming_announce_frequency"]
|
||||
if "outgoing_announce_frequency" in ifstat:
|
||||
formatted_if["outgoing_announce_frequency"] = ifstat["outgoing_announce_frequency"]
|
||||
if "held_announces" in ifstat:
|
||||
formatted_if["held_announces"] = ifstat["held_announces"]
|
||||
|
||||
if "ifac_netname" in ifstat and ifstat["ifac_netname"] is not None:
|
||||
formatted_if["network_name"] = ifstat["ifac_netname"]
|
||||
|
||||
formatted_interfaces.append(formatted_if)
|
||||
|
||||
return {
|
||||
"interfaces": formatted_interfaces,
|
||||
"link_count": link_count,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
3
meshchatx/src/backend/sideband_commands.py
Normal file
3
meshchatx/src/backend/sideband_commands.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://github.com/markqvist/Sideband/blob/e515889e210037f881c201e0d627a7b09a48eb69/sbapp/sideband/sense.py#L11
|
||||
class SidebandCommands:
|
||||
TELEMETRY_REQUEST = 0x01
|
||||
95
meshchatx/src/backend/telephone_manager.py
Normal file
95
meshchatx/src/backend/telephone_manager.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import RNS
|
||||
from LXST import Telephone
|
||||
|
||||
|
||||
class TelephoneManager:
|
||||
def __init__(self, identity: RNS.Identity, config_manager=None):
|
||||
self.identity = identity
|
||||
self.config_manager = config_manager
|
||||
self.telephone = None
|
||||
self.on_ringing_callback = None
|
||||
self.on_established_callback = None
|
||||
self.on_ended_callback = None
|
||||
|
||||
self.call_start_time = None
|
||||
self.call_status_at_end = None
|
||||
self.call_is_incoming = False
|
||||
|
||||
def init_telephone(self):
|
||||
if self.telephone is not None:
|
||||
return
|
||||
|
||||
self.telephone = Telephone(self.identity)
|
||||
# Disable busy tone played on caller side when remote side rejects, or doesn't answer
|
||||
self.telephone.set_busy_tone_time(0)
|
||||
self.telephone.set_ringing_callback(self.on_telephone_ringing)
|
||||
self.telephone.set_established_callback(self.on_telephone_call_established)
|
||||
self.telephone.set_ended_callback(self.on_telephone_call_ended)
|
||||
|
||||
def teardown(self):
|
||||
if self.telephone is not None:
|
||||
self.telephone.teardown()
|
||||
self.telephone = None
|
||||
|
||||
def register_ringing_callback(self, callback):
|
||||
self.on_ringing_callback = callback
|
||||
|
||||
def register_established_callback(self, callback):
|
||||
self.on_established_callback = callback
|
||||
|
||||
def register_ended_callback(self, callback):
|
||||
self.on_ended_callback = callback
|
||||
|
||||
def on_telephone_ringing(self, caller_identity: RNS.Identity):
|
||||
self.call_start_time = time.time()
|
||||
self.call_is_incoming = True
|
||||
if self.on_ringing_callback:
|
||||
self.on_ringing_callback(caller_identity)
|
||||
|
||||
def on_telephone_call_established(self, caller_identity: RNS.Identity):
|
||||
# Update start time to when it was actually established for duration calculation
|
||||
self.call_start_time = time.time()
|
||||
if self.on_established_callback:
|
||||
self.on_established_callback(caller_identity)
|
||||
|
||||
def on_telephone_call_ended(self, caller_identity: RNS.Identity):
|
||||
# Capture status just before ending if possible, or use the last known status
|
||||
if self.telephone:
|
||||
self.call_status_at_end = self.telephone.call_status
|
||||
|
||||
if self.on_ended_callback:
|
||||
self.on_ended_callback(caller_identity)
|
||||
|
||||
def announce(self, attached_interface=None):
|
||||
if self.telephone:
|
||||
self.telephone.announce(attached_interface=attached_interface)
|
||||
|
||||
async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15):
|
||||
if self.telephone is None:
|
||||
msg = "Telephone is not initialized"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
# Find destination identity
|
||||
destination_identity = RNS.Identity.recall(destination_hash)
|
||||
if destination_identity is None:
|
||||
# If not found by identity hash, try as destination hash
|
||||
destination_identity = RNS.Identity.recall(destination_hash) # Identity.recall takes identity hash
|
||||
|
||||
if destination_identity is None:
|
||||
msg = "Destination identity not found"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
# In LXST, we just call the identity. Telephone class handles path requests.
|
||||
# But we might want to ensure a path exists first for better UX,
|
||||
# similar to how the old MeshChat did it.
|
||||
|
||||
# For now, let's just use the telephone.call method which is threaded.
|
||||
# We need to run it in a thread since it might block.
|
||||
self.call_start_time = time.time()
|
||||
self.call_is_incoming = False
|
||||
await asyncio.to_thread(self.telephone.call, destination_identity)
|
||||
return self.telephone.active_call
|
||||
|
||||
363
meshchatx/src/backend/translator_handler.py
Normal file
363
meshchatx/src/backend/translator_handler.py
Normal file
@@ -0,0 +1,363 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import requests
|
||||
HAS_REQUESTS = True
|
||||
except ImportError:
|
||||
HAS_REQUESTS = False
|
||||
|
||||
try:
|
||||
from argostranslate import package, translate
|
||||
HAS_ARGOS_LIB = True
|
||||
except ImportError:
|
||||
HAS_ARGOS_LIB = False
|
||||
|
||||
HAS_ARGOS_CLI = shutil.which("argos-translate") is not None
|
||||
HAS_ARGOS = HAS_ARGOS_LIB or HAS_ARGOS_CLI
|
||||
|
||||
LANGUAGE_CODE_TO_NAME = {
|
||||
"en": "English",
|
||||
"de": "German",
|
||||
"es": "Spanish",
|
||||
"fr": "French",
|
||||
"it": "Italian",
|
||||
"pt": "Portuguese",
|
||||
"ru": "Russian",
|
||||
"zh": "Chinese",
|
||||
"ja": "Japanese",
|
||||
"ko": "Korean",
|
||||
"ar": "Arabic",
|
||||
"hi": "Hindi",
|
||||
"nl": "Dutch",
|
||||
"pl": "Polish",
|
||||
"tr": "Turkish",
|
||||
"sv": "Swedish",
|
||||
"da": "Danish",
|
||||
"no": "Norwegian",
|
||||
"fi": "Finnish",
|
||||
"cs": "Czech",
|
||||
"ro": "Romanian",
|
||||
"hu": "Hungarian",
|
||||
"el": "Greek",
|
||||
"he": "Hebrew",
|
||||
"th": "Thai",
|
||||
"vi": "Vietnamese",
|
||||
"id": "Indonesian",
|
||||
"uk": "Ukrainian",
|
||||
"bg": "Bulgarian",
|
||||
"hr": "Croatian",
|
||||
"sk": "Slovak",
|
||||
"sl": "Slovenian",
|
||||
"et": "Estonian",
|
||||
"lv": "Latvian",
|
||||
"lt": "Lithuanian",
|
||||
"mt": "Maltese",
|
||||
"ga": "Irish",
|
||||
"cy": "Welsh",
|
||||
}
|
||||
|
||||
|
||||
class TranslatorHandler:
|
||||
def __init__(self, libretranslate_url: str | None = None):
|
||||
self.libretranslate_url = libretranslate_url or os.getenv("LIBRETRANSLATE_URL", "http://localhost:5000")
|
||||
self.has_argos = HAS_ARGOS
|
||||
self.has_argos_lib = HAS_ARGOS_LIB
|
||||
self.has_argos_cli = HAS_ARGOS_CLI
|
||||
self.has_requests = HAS_REQUESTS
|
||||
|
||||
def get_supported_languages(self, libretranslate_url: str | None = None):
|
||||
languages = []
|
||||
url = libretranslate_url or self.libretranslate_url
|
||||
|
||||
if self.has_requests:
|
||||
try:
|
||||
response = requests.get(f"{url}/languages", timeout=5)
|
||||
if response.status_code == 200:
|
||||
libretranslate_langs = response.json()
|
||||
languages.extend(
|
||||
{
|
||||
"code": lang.get("code"),
|
||||
"name": lang.get("name"),
|
||||
"source": "libretranslate",
|
||||
}
|
||||
for lang in libretranslate_langs
|
||||
)
|
||||
return languages
|
||||
except Exception as e:
|
||||
# Log or handle the exception appropriately
|
||||
print(f"Failed to fetch LibreTranslate languages: {e}")
|
||||
|
||||
if self.has_argos_lib:
|
||||
try:
|
||||
installed_packages = package.get_installed_packages()
|
||||
argos_langs = set()
|
||||
for pkg in installed_packages:
|
||||
argos_langs.add((pkg.from_code, pkg.from_name))
|
||||
argos_langs.add((pkg.to_code, pkg.to_name))
|
||||
|
||||
for code, name in sorted(argos_langs):
|
||||
languages.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": name,
|
||||
"source": "argos",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch Argos languages: {e}")
|
||||
elif self.has_argos_cli:
|
||||
try:
|
||||
cli_langs = self._get_argos_languages_cli()
|
||||
languages.extend(cli_langs)
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch Argos languages via CLI: {e}")
|
||||
|
||||
return languages
|
||||
|
||||
def translate_text(
|
||||
self,
|
||||
text: str,
|
||||
source_lang: str,
|
||||
target_lang: str,
|
||||
use_argos: bool = False,
|
||||
libretranslate_url: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not text:
|
||||
msg = "Text cannot be empty"
|
||||
raise ValueError(msg)
|
||||
|
||||
if use_argos and self.has_argos:
|
||||
return self._translate_argos(text, source_lang, target_lang)
|
||||
|
||||
if self.has_requests:
|
||||
try:
|
||||
url = libretranslate_url or self.libretranslate_url
|
||||
return self._translate_libretranslate(text, source_lang=source_lang, target_lang=target_lang, libretranslate_url=url)
|
||||
except Exception as e:
|
||||
if self.has_argos:
|
||||
return self._translate_argos(text, source_lang, target_lang)
|
||||
raise e
|
||||
|
||||
if self.has_argos:
|
||||
return self._translate_argos(text, source_lang, target_lang)
|
||||
|
||||
msg = "No translation backend available. Install requests for LibreTranslate or argostranslate for local translation."
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def _translate_libretranslate(self, text: str, source_lang: str, target_lang: str, libretranslate_url: str | None = None) -> dict[str, Any]:
|
||||
if not self.has_requests:
|
||||
msg = "requests library not available"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
url = libretranslate_url or self.libretranslate_url
|
||||
response = requests.post(
|
||||
f"{url}/translate",
|
||||
json={
|
||||
"q": text,
|
||||
"source": source_lang,
|
||||
"target": target_lang,
|
||||
"format": "text",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
msg = f"LibreTranslate API error: {response.status_code} - {response.text}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
result = response.json()
|
||||
return {
|
||||
"translated_text": result.get("translatedText", ""),
|
||||
"source_lang": result.get("detectedLanguage", {}).get("language", source_lang),
|
||||
"target_lang": target_lang,
|
||||
"source": "libretranslate",
|
||||
}
|
||||
|
||||
def _translate_argos(self, text: str, source_lang: str, target_lang: str) -> dict[str, Any]:
|
||||
if source_lang == "auto":
|
||||
if self.has_argos_lib:
|
||||
detected_lang = self._detect_language(text)
|
||||
if detected_lang:
|
||||
source_lang = detected_lang
|
||||
else:
|
||||
msg = "Could not auto-detect language. Please select a source language manually."
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
msg = (
|
||||
"Auto-detection is not supported with CLI-only installation. "
|
||||
"Please select a source language manually or install the Python library: pip install argostranslate"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if self.has_argos_lib:
|
||||
return self._translate_argos_lib(text, source_lang, target_lang)
|
||||
if self.has_argos_cli:
|
||||
return self._translate_argos_cli(text, source_lang, target_lang)
|
||||
msg = "Argos Translate not available (neither library nor CLI)"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def _translate_argos_lib(self, text: str, source_lang: str, target_lang: str) -> dict[str, Any]:
|
||||
try:
|
||||
installed_packages = package.get_installed_packages()
|
||||
translation_package = None
|
||||
|
||||
for pkg in installed_packages:
|
||||
if pkg.from_code == source_lang and pkg.to_code == target_lang:
|
||||
translation_package = pkg
|
||||
break
|
||||
|
||||
if translation_package is None:
|
||||
msg = (
|
||||
f"No translation package found for {source_lang} -> {target_lang}. "
|
||||
"Install packages using: argostranslate --update-languages"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
translated_text = translate.translate(text, source_lang, target_lang)
|
||||
return {
|
||||
"translated_text": translated_text,
|
||||
"source_lang": source_lang,
|
||||
"target_lang": target_lang,
|
||||
"source": "argos",
|
||||
}
|
||||
except Exception as e:
|
||||
msg = f"Argos Translate error: {e}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def _translate_argos_cli(self, text: str, source_lang: str, target_lang: str) -> dict[str, Any]:
|
||||
if source_lang == "auto" or not source_lang:
|
||||
msg = "Auto-detection is not supported with CLI. Please select a source language manually."
|
||||
raise ValueError(msg)
|
||||
|
||||
if not target_lang:
|
||||
msg = "Target language is required."
|
||||
raise ValueError(msg)
|
||||
|
||||
if not isinstance(source_lang, str) or not isinstance(target_lang, str):
|
||||
msg = "Language codes must be strings."
|
||||
raise ValueError(msg)
|
||||
|
||||
if len(source_lang) != 2 or len(target_lang) != 2:
|
||||
msg = f"Invalid language codes: {source_lang} -> {target_lang}"
|
||||
raise ValueError(msg)
|
||||
|
||||
executable = shutil.which("argos-translate")
|
||||
if not executable:
|
||||
msg = "argos-translate executable not found in PATH"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
try:
|
||||
args = [executable, "--from-lang", source_lang, "--to-lang", target_lang, text]
|
||||
result = subprocess.run(args, capture_output=True, text=True, check=True) # noqa: S603
|
||||
translated_text = result.stdout.strip()
|
||||
if not translated_text:
|
||||
msg = "Translation returned empty result"
|
||||
raise RuntimeError(msg)
|
||||
return {
|
||||
"translated_text": translated_text,
|
||||
"source_lang": source_lang,
|
||||
"target_lang": target_lang,
|
||||
"source": "argos",
|
||||
}
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_msg = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or str(e))
|
||||
msg = f"Argos Translate CLI error: {error_msg}"
|
||||
raise RuntimeError(msg)
|
||||
except Exception as e:
|
||||
msg = f"Argos Translate CLI error: {e!s}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def _detect_language(self, text: str) -> str | None:
|
||||
if not self.has_argos_lib:
|
||||
return None
|
||||
|
||||
try:
|
||||
from argostranslate import translate
|
||||
|
||||
installed_packages = package.get_installed_packages()
|
||||
if not installed_packages:
|
||||
return None
|
||||
|
||||
detected = translate.detect_language(text)
|
||||
if detected:
|
||||
return detected.code
|
||||
except Exception as e:
|
||||
print(f"Language detection failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _get_argos_languages_cli(self) -> list[dict[str, str]]:
|
||||
languages = []
|
||||
argospm = shutil.which("argospm")
|
||||
if not argospm:
|
||||
return languages
|
||||
|
||||
try:
|
||||
result = subprocess.run( # noqa: S603
|
||||
[argospm, "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=True,
|
||||
)
|
||||
installed_packages = result.stdout.strip().split("\n")
|
||||
argos_langs = set()
|
||||
|
||||
for pkg_name in installed_packages:
|
||||
if not pkg_name.strip():
|
||||
continue
|
||||
match = re.match(r"translate-([a-z]{2})_([a-z]{2})", pkg_name.strip())
|
||||
if match:
|
||||
from_code = match.group(1)
|
||||
to_code = match.group(2)
|
||||
argos_langs.add(from_code)
|
||||
argos_langs.add(to_code)
|
||||
|
||||
for code in sorted(argos_langs):
|
||||
name = LANGUAGE_CODE_TO_NAME.get(code, code.upper())
|
||||
languages.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": name,
|
||||
"source": "argos",
|
||||
},
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"argospm list failed: {e.stderr or str(e)}")
|
||||
except Exception as e:
|
||||
print(f"Error parsing argospm output: {e}")
|
||||
|
||||
return languages
|
||||
|
||||
def install_language_package(self, package_name: str = "translate") -> dict[str, Any]:
|
||||
argospm = shutil.which("argospm")
|
||||
if not argospm:
|
||||
msg = "argospm not found in PATH. Install argostranslate first."
|
||||
raise RuntimeError(msg)
|
||||
|
||||
try:
|
||||
result = subprocess.run( # noqa: S603
|
||||
[argospm, "install", package_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
check=True,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Successfully installed {package_name}",
|
||||
"output": result.stdout,
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
msg = f"Installation of {package_name} timed out after 5 minutes"
|
||||
raise RuntimeError(msg)
|
||||
except subprocess.CalledProcessError as e:
|
||||
msg = f"Failed to install {package_name}: {e.stderr or str(e)}"
|
||||
raise RuntimeError(msg)
|
||||
except Exception as e:
|
||||
msg = f"Error installing {package_name}: {e!s}"
|
||||
raise RuntimeError(msg)
|
||||
731
meshchatx/src/frontend/components/App.vue
Normal file
731
meshchatx/src/frontend/components/App.vue
Normal file
@@ -0,0 +1,731 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{ dark: config?.theme === 'dark' }"
|
||||
class="h-screen w-full flex flex-col bg-slate-50 dark:bg-zinc-950 transition-colors"
|
||||
>
|
||||
<RouterView v-if="$route.name === 'auth'" />
|
||||
|
||||
<template v-else>
|
||||
<div v-if="isPopoutMode" class="flex flex-1 h-full w-full overflow-hidden bg-slate-50/90 dark:bg-zinc-950">
|
||||
<RouterView class="flex-1" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- header -->
|
||||
<div
|
||||
class="relative z-[60] flex bg-white/80 dark:bg-zinc-900/70 backdrop-blur border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-sm transition-colors"
|
||||
>
|
||||
<div class="flex w-full px-4">
|
||||
<button
|
||||
type="button"
|
||||
class="sm:hidden my-auto mr-4 text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
@click="isSidebarOpen = !isSidebarOpen"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="isSidebarOpen ? 'close' : 'menu'" class="size-6" />
|
||||
</button>
|
||||
<div
|
||||
class="hidden sm:flex my-auto w-12 h-12 mr-2 rounded-xl overflow-hidden bg-white/70 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 shadow-inner"
|
||||
>
|
||||
<img class="w-12 h-12 object-contain p-1.5" src="/assets/images/logo-chat-bubble.png" />
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<div
|
||||
class="font-semibold cursor-pointer text-gray-900 dark:text-zinc-100 tracking-tight text-lg"
|
||||
@click="onAppNameClick"
|
||||
>
|
||||
{{ $t("app.name") }}
|
||||
</div>
|
||||
<div class="hidden sm:block text-sm text-gray-600 dark:text-zinc-300">
|
||||
{{ $t("app.custom_fork_by") }}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://github.com/Sudo-Ivan"
|
||||
class="text-blue-500 dark:text-blue-300 hover:underline"
|
||||
>Sudo-Ivan</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
class="relative rounded-full p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
:title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
|
||||
class="w-6 h-6"
|
||||
/>
|
||||
</button>
|
||||
<LanguageSelector @language-change="onLanguageChange" />
|
||||
<NotificationBell />
|
||||
<button type="button" class="rounded-full" @click="syncPropagationNode">
|
||||
<span
|
||||
class="flex text-gray-800 dark:text-zinc-100 bg-white dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 hover:border-blue-400 dark:hover:border-blue-400/60 px-3 py-1.5 rounded-full shadow-sm transition"
|
||||
>
|
||||
<span :class="{ 'animate-spin': isSyncingPropagationNode }">
|
||||
<MaterialDesignIcon icon-name="refresh" class="size-6" />
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-medium">{{
|
||||
$t("app.sync_messages")
|
||||
}}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="rounded-full" @click="composeNewMessage">
|
||||
<span
|
||||
class="flex text-white bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 hover:from-blue-500/90 hover:to-purple-500/90 px-3 py-1.5 rounded-full shadow-md transition"
|
||||
>
|
||||
<span>
|
||||
<MaterialDesignIcon icon-name="email" class="w-6 h-6" />
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-semibold">{{
|
||||
$t("app.compose")
|
||||
}}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- middle -->
|
||||
<div
|
||||
ref="middle"
|
||||
class="flex flex-1 w-full overflow-hidden bg-slate-50/80 dark:bg-zinc-950 transition-colors"
|
||||
>
|
||||
<!-- sidebar backdrop for mobile -->
|
||||
<div
|
||||
v-if="isSidebarOpen"
|
||||
class="fixed inset-0 z-[65] bg-black/20 backdrop-blur-sm sm:hidden"
|
||||
@click="isSidebarOpen = false"
|
||||
></div>
|
||||
|
||||
<!-- sidebar -->
|
||||
<div
|
||||
class="fixed inset-y-0 left-0 z-[70] w-72 transform transition-transform duration-300 ease-in-out sm:relative sm:z-0 sm:flex sm:translate-x-0"
|
||||
:class="isSidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div
|
||||
class="flex h-full w-full flex-col overflow-y-auto border-r border-gray-200/70 bg-white dark:border-zinc-800 dark:bg-zinc-900 backdrop-blur"
|
||||
>
|
||||
<!-- navigation -->
|
||||
<div class="flex-1">
|
||||
<ul class="py-3 pr-2 space-y-1">
|
||||
<!-- messages -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'messages' }">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon
|
||||
icon-name="message-text"
|
||||
class="w-6 h-6 dark:text-white"
|
||||
/>
|
||||
</template>
|
||||
<template #text>
|
||||
<span>{{ $t("app.messages") }}</span>
|
||||
<span v-if="unreadConversationsCount > 0" class="ml-auto mr-2">{{
|
||||
unreadConversationsCount
|
||||
}}</span>
|
||||
</template>
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- nomad network -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'nomadnetwork' }">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="earth" class="w-6 h-6" />
|
||||
</template>
|
||||
<template #text>{{ $t("app.nomad_network") }}</template>
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- map -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'map' }">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="map" class="w-6 h-6" />
|
||||
</template>
|
||||
<template #text>{{ $t("app.map") }}</template>
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- archives -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'archives' }">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="archive" class="w-6 h-6" />
|
||||
</template>
|
||||
<template #text>{{ $t("app.archives") }}</template>
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- interfaces -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'interfaces' }">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="router" class="w-6 h-6" />
|
||||
</template>
|
||||
<template #text>{{ $t("app.interfaces") }}</template>
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- network visualiser -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'network-visualiser' }">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="diagram-projector" class="w-6 h-6" />
|
||||
</template>
|
||||
<template #text>{{ $t("app.network_visualiser") }}</template>
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- tools -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'tools' }">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="wrench" class="size-6" />
|
||||
</template>
|
||||
<template #text>{{ $t("app.tools") }}</template>
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- settings -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'settings' }">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="cog" class="size-6" />
|
||||
</template>
|
||||
<template #text>{{ $t("app.settings") }}</template>
|
||||
</SidebarLink>
|
||||
</li>
|
||||
|
||||
<!-- info -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'about' }">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="information" class="size-6" />
|
||||
</template>
|
||||
<template #text>{{ $t("app.about") }}</template>
|
||||
</SidebarLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- my identity -->
|
||||
<div
|
||||
v-if="config"
|
||||
class="bg-white/80 border-t dark:border-zinc-800 dark:bg-zinc-900/70 backdrop-blur"
|
||||
>
|
||||
<div
|
||||
class="flex text-gray-700 p-3 cursor-pointer"
|
||||
@click="isShowingMyIdentitySection = !isShowingMyIdentitySection"
|
||||
>
|
||||
<div class="my-auto mr-2">
|
||||
<RouterLink :to="{ name: 'profile.icon' }" @click.stop>
|
||||
<LxmfUserIcon
|
||||
:icon-name="config?.lxmf_user_icon_name"
|
||||
:icon-foreground-colour="config?.lxmf_user_icon_foreground_colour"
|
||||
:icon-background-colour="config?.lxmf_user_icon_background_colour"
|
||||
/>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="my-auto dark:text-white">{{ $t("app.my_identity") }}</div>
|
||||
<div class="my-auto ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500"
|
||||
@click.stop="saveIdentitySettings"
|
||||
>
|
||||
{{ $t("common.save") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isShowingMyIdentitySection"
|
||||
class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
<div class="p-2">
|
||||
<input
|
||||
v-model="displayName"
|
||||
type="text"
|
||||
:placeholder="$t('app.display_name_placeholder')"
|
||||
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-800 dark:border-zinc-600 dark:text-zinc-200 dark:focus:ring-blue-400 dark:focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-2 dark:border-zinc-900">
|
||||
<div>{{ $t("app.identity_hash") }}</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
||||
{{ config.identity_hash }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 dark:border-zinc-900">
|
||||
<div>{{ $t("app.lxmf_address") }}</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
||||
{{ config.lxmf_address_hash }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- auto announce -->
|
||||
<div
|
||||
v-if="config"
|
||||
class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-800"
|
||||
>
|
||||
<div
|
||||
class="flex text-gray-700 p-3 cursor-pointer dark:text-white"
|
||||
@click="isShowingAnnounceSection = !isShowingAnnounceSection"
|
||||
>
|
||||
<div class="my-auto mr-2">
|
||||
<MaterialDesignIcon icon-name="radio" class="size-6" />
|
||||
</div>
|
||||
<div class="my-auto">{{ $t("app.announce") }}</div>
|
||||
<div class="ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500"
|
||||
@click.stop="sendAnnounce"
|
||||
>
|
||||
{{ $t("app.announce_now") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isShowingAnnounceSection"
|
||||
class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
<div class="p-2 dark:border-zinc-800">
|
||||
<select
|
||||
v-model="config.auto_announce_interval_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-800 dark:border-zinc-600 dark:text-zinc-200 dark:focus:ring-blue-400 dark:focus:border-blue-400"
|
||||
@change="onAnnounceIntervalSecondsChange"
|
||||
>
|
||||
<option value="0">{{ $t("app.disabled") }}</option>
|
||||
<option value="900">Every 15 Minutes</option>
|
||||
<option value="1800">Every 30 Minutes</option>
|
||||
<option value="3600">Every 1 Hour</option>
|
||||
<option value="10800">Every 3 Hours</option>
|
||||
<option value="21600">Every 6 Hours</option>
|
||||
<option value="43200">Every 12 Hours</option>
|
||||
<option value="86400">Every 24 Hours</option>
|
||||
</select>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-100">
|
||||
<span v-if="config.last_announced_at">{{
|
||||
$t("app.last_announced", {
|
||||
time: formatSecondsAgo(config.last_announced_at),
|
||||
})
|
||||
}}</span>
|
||||
<span v-else>{{ $t("app.last_announced_never") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- audio calls -->
|
||||
<div
|
||||
v-if="config"
|
||||
class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-800 pb-3"
|
||||
>
|
||||
<div
|
||||
class="flex text-gray-700 p-3 cursor-pointer"
|
||||
@click="isShowingCallsSection = !isShowingCallsSection"
|
||||
>
|
||||
<div class="my-auto mr-2">
|
||||
<MaterialDesignIcon icon-name="phone" class="dark:text-white w-6 h-6" />
|
||||
</div>
|
||||
<div class="my-auto dark:text-white">{{ $t("app.calls") }}</div>
|
||||
<div class="ml-auto">
|
||||
<RouterLink
|
||||
:to="{ name: 'call' }"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 transition-colors"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="phone"
|
||||
class="w-3.5 h-3.5 flex-shrink-0"
|
||||
/>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isShowingCallsSection"
|
||||
class="divide-y text-gray-900 border-t border-gray-200 dark:border-zinc-800"
|
||||
>
|
||||
<div class="p-2 flex dark:border-zinc-800 dark:text-white">
|
||||
<div>
|
||||
<div>{{ $t("app.status") }}</div>
|
||||
<div class="text-sm text-gray-700 dark:text-white">
|
||||
<div v-if="isTelephoneCallActive" class="flex space-x-2">
|
||||
<span>{{ $t("app.active_call") }}</span>
|
||||
</div>
|
||||
<div v-else>{{ $t("app.hung_up_waiting") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isTelephoneCallActive" class="ml-auto my-auto mr-1 space-x-2">
|
||||
<!-- hangup all calls -->
|
||||
<button
|
||||
:title="$t('app.hangup_all_calls')"
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-full bg-red-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
|
||||
@click="hangupTelephoneCall"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="phone-hangup"
|
||||
class="w-5 h-5 rotate-[135deg] translate-y-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPopoutMode" class="flex flex-1 min-w-0 overflow-hidden">
|
||||
<RouterView class="flex-1 min-w-0 h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<CallOverlay
|
||||
v-if="activeCall || isCallEnded"
|
||||
:active-call="activeCall || lastCall"
|
||||
:is-ended="isCallEnded"
|
||||
/>
|
||||
<Toast />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SidebarLink from "./SidebarLink.vue";
|
||||
import DialogUtils from "../js/DialogUtils";
|
||||
import WebSocketConnection from "../js/WebSocketConnection";
|
||||
import GlobalState from "../js/GlobalState";
|
||||
import Utils from "../js/Utils";
|
||||
import GlobalEmitter from "../js/GlobalEmitter";
|
||||
import NotificationUtils from "../js/NotificationUtils";
|
||||
import LxmfUserIcon from "./LxmfUserIcon.vue";
|
||||
import Toast from "./Toast.vue";
|
||||
import ToastUtils from "../js/ToastUtils";
|
||||
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
|
||||
import NotificationBell from "./NotificationBell.vue";
|
||||
import LanguageSelector from "./LanguageSelector.vue";
|
||||
import CallOverlay from "./call/CallOverlay.vue";
|
||||
|
||||
export default {
|
||||
name: "App",
|
||||
components: {
|
||||
LxmfUserIcon,
|
||||
SidebarLink,
|
||||
Toast,
|
||||
MaterialDesignIcon,
|
||||
NotificationBell,
|
||||
LanguageSelector,
|
||||
CallOverlay,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
reloadInterval: null,
|
||||
appInfoInterval: null,
|
||||
|
||||
isShowingMyIdentitySection: true,
|
||||
isShowingAnnounceSection: true,
|
||||
isShowingCallsSection: true,
|
||||
|
||||
isSidebarOpen: false,
|
||||
|
||||
displayName: "Anonymous Peer",
|
||||
config: null,
|
||||
appInfo: null,
|
||||
|
||||
isTelephoneCallActive: false,
|
||||
activeCall: null,
|
||||
propagationNodeStatus: null,
|
||||
isCallEnded: false,
|
||||
lastCall: null,
|
||||
endedTimeout: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
currentPopoutType() {
|
||||
if (this.$route?.meta?.popoutType) {
|
||||
return this.$route.meta.popoutType;
|
||||
}
|
||||
return this.$route?.query?.popout ?? this.getHashPopoutValue();
|
||||
},
|
||||
isPopoutMode() {
|
||||
return this.currentPopoutType != null;
|
||||
},
|
||||
unreadConversationsCount() {
|
||||
return GlobalState.unreadConversationsCount;
|
||||
},
|
||||
isSyncingPropagationNode() {
|
||||
return [
|
||||
"path_requested",
|
||||
"link_establishing",
|
||||
"link_established",
|
||||
"request_sent",
|
||||
"receiving",
|
||||
"response_received",
|
||||
"complete",
|
||||
].includes(this.propagationNodeStatus?.state);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.isSidebarOpen = false;
|
||||
},
|
||||
config: {
|
||||
handler(newConfig) {
|
||||
if (newConfig && newConfig.language) {
|
||||
this.$i18n.locale = newConfig.language;
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearInterval(this.reloadInterval);
|
||||
clearInterval(this.appInfoInterval);
|
||||
if (this.endedTimeout) clearTimeout(this.endedTimeout);
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
},
|
||||
mounted() {
|
||||
// listen for websocket messages
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
|
||||
this.getAppInfo();
|
||||
this.getConfig();
|
||||
this.updateTelephoneStatus();
|
||||
this.updatePropagationNodeStatus();
|
||||
|
||||
// update info every few seconds
|
||||
this.reloadInterval = setInterval(() => {
|
||||
this.updateTelephoneStatus();
|
||||
this.updatePropagationNodeStatus();
|
||||
}, 1000);
|
||||
this.appInfoInterval = setInterval(() => {
|
||||
this.getAppInfo();
|
||||
}, 15000);
|
||||
},
|
||||
methods: {
|
||||
getHashPopoutValue() {
|
||||
const hash = window.location.hash || "";
|
||||
const match = hash.match(/popout=([^&]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
async onWebsocketMessage(message) {
|
||||
const json = JSON.parse(message.data);
|
||||
switch (json.type) {
|
||||
case "config": {
|
||||
this.config = json.config;
|
||||
this.displayName = json.config.display_name;
|
||||
break;
|
||||
}
|
||||
case "announced": {
|
||||
// we just announced, update config so we can show the new last updated at
|
||||
this.getConfig();
|
||||
break;
|
||||
}
|
||||
case "telephone_ringing": {
|
||||
NotificationUtils.showIncomingCallNotification();
|
||||
this.updateTelephoneStatus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
async getAppInfo() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/app/info`);
|
||||
this.appInfo = response.data.app_info;
|
||||
} catch (e) {
|
||||
// do nothing if failed to load app info
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async getConfig() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/config`);
|
||||
this.config = response.data.config;
|
||||
} catch (e) {
|
||||
// do nothing if failed to load config
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async sendAnnounce() {
|
||||
try {
|
||||
await window.axios.get(`/api/v1/announce`);
|
||||
} catch (e) {
|
||||
ToastUtils.error("failed to announce");
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
// fetch config so it updates last announced timestamp
|
||||
await this.getConfig();
|
||||
},
|
||||
async updateConfig(config) {
|
||||
try {
|
||||
WebSocketConnection.send(
|
||||
JSON.stringify({
|
||||
type: "config.set",
|
||||
config: config,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
async saveIdentitySettings() {
|
||||
await this.updateConfig({
|
||||
display_name: this.displayName,
|
||||
});
|
||||
},
|
||||
async onAnnounceIntervalSecondsChange() {
|
||||
await this.updateConfig({
|
||||
auto_announce_interval_seconds: this.config.auto_announce_interval_seconds,
|
||||
});
|
||||
},
|
||||
async toggleTheme() {
|
||||
if (!this.config) {
|
||||
return;
|
||||
}
|
||||
const newTheme = this.config.theme === "dark" ? "light" : "dark";
|
||||
await this.updateConfig({
|
||||
theme: newTheme,
|
||||
});
|
||||
},
|
||||
async onLanguageChange(langCode) {
|
||||
await this.updateConfig({
|
||||
language: langCode,
|
||||
});
|
||||
this.$i18n.locale = langCode;
|
||||
},
|
||||
async composeNewMessage() {
|
||||
// go to messages route
|
||||
await this.$router.push({ name: "messages" });
|
||||
|
||||
// emit global event handled by MessagesPage
|
||||
GlobalEmitter.emit("compose-new-message");
|
||||
},
|
||||
async syncPropagationNode() {
|
||||
// ask to stop syncing if already syncing
|
||||
if (this.isSyncingPropagationNode) {
|
||||
if (await DialogUtils.confirm("Are you sure you want to stop syncing?")) {
|
||||
await this.stopSyncingPropagationNode();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// request sync
|
||||
try {
|
||||
await axios.get("/api/v1/lxmf/propagation-node/sync");
|
||||
} catch (e) {
|
||||
const errorMessage = e.response?.data?.message ?? "Something went wrong. Try again later.";
|
||||
ToastUtils.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// update propagation status
|
||||
await this.updatePropagationNodeStatus();
|
||||
|
||||
// wait until sync has finished
|
||||
const syncFinishedInterval = setInterval(() => {
|
||||
// do nothing if still syncing
|
||||
if (this.isSyncingPropagationNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// finished syncing, stop checking
|
||||
clearInterval(syncFinishedInterval);
|
||||
|
||||
// show result
|
||||
const status = this.propagationNodeStatus?.state;
|
||||
const messagesReceived = this.propagationNodeStatus?.messages_received ?? 0;
|
||||
if (status === "complete" || status === "idle") {
|
||||
ToastUtils.success(`Sync complete. ${messagesReceived} messages received.`);
|
||||
} else {
|
||||
ToastUtils.error(`Sync error: ${status}`);
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
async stopSyncingPropagationNode() {
|
||||
// stop sync
|
||||
try {
|
||||
await axios.get("/api/v1/lxmf/propagation-node/stop-sync");
|
||||
} catch {
|
||||
// do nothing on error
|
||||
}
|
||||
|
||||
// update propagation status
|
||||
await this.updatePropagationNodeStatus();
|
||||
},
|
||||
async updatePropagationNodeStatus() {
|
||||
try {
|
||||
const response = await axios.get("/api/v1/lxmf/propagation-node/status");
|
||||
this.propagationNodeStatus = response.data.propagation_node_status;
|
||||
} catch {
|
||||
// do nothing on error
|
||||
}
|
||||
},
|
||||
formatSecondsAgo: function (seconds) {
|
||||
return Utils.formatSecondsAgo(seconds);
|
||||
},
|
||||
async updateTelephoneStatus() {
|
||||
try {
|
||||
// fetch status
|
||||
const response = await axios.get("/api/v1/telephone/status");
|
||||
const oldCall = this.activeCall;
|
||||
|
||||
// update ui
|
||||
this.activeCall = response.data.active_call;
|
||||
this.isTelephoneCallActive = this.activeCall != null;
|
||||
|
||||
// If call just ended, show ended state for a few seconds
|
||||
if (oldCall != null && this.activeCall == null) {
|
||||
this.lastCall = oldCall;
|
||||
this.isCallEnded = true;
|
||||
|
||||
if (this.endedTimeout) clearTimeout(this.endedTimeout);
|
||||
this.endedTimeout = setTimeout(() => {
|
||||
this.isCallEnded = false;
|
||||
this.lastCall = null;
|
||||
}, 5000);
|
||||
} else if (this.activeCall != null) {
|
||||
// if a new call starts, clear ended state
|
||||
this.isCallEnded = false;
|
||||
this.lastCall = null;
|
||||
if (this.endedTimeout) clearTimeout(this.endedTimeout);
|
||||
}
|
||||
} catch {
|
||||
// do nothing on error
|
||||
}
|
||||
},
|
||||
async hangupTelephoneCall() {
|
||||
// confirm user wants to hang up call
|
||||
if (!(await DialogUtils.confirm("Are you sure you want to hang up the current telephone call?"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// hangup call
|
||||
await axios.get(`/api/v1/telephone/hangup`);
|
||||
|
||||
// reload status
|
||||
await this.updateTelephoneStatus();
|
||||
} catch {
|
||||
// ignore error hanging up call
|
||||
}
|
||||
},
|
||||
onAppNameClick() {
|
||||
// user may be on mobile, and is unable to scroll back to sidebar, so let them tap app name to do it
|
||||
this.$refs["middle"].scrollTo({
|
||||
top: 0,
|
||||
left: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
152
meshchatx/src/frontend/components/CardStack.vue
Normal file
152
meshchatx/src/frontend/components/CardStack.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="card-stack-wrapper" :class="{ 'is-expanded': isExpanded }">
|
||||
<div
|
||||
v-if="items && items.length > 0"
|
||||
class="relative"
|
||||
:class="{ 'stack-mode': !isExpanded && items.length > 1, 'grid-mode': isExpanded || items.length === 1 }"
|
||||
>
|
||||
<!-- Grid Mode (Expanded or only 1 item) -->
|
||||
<div v-if="isExpanded || items.length === 1" :class="gridClass">
|
||||
<div v-for="(item, index) in items" :key="index" class="w-full">
|
||||
<slot :item="item" :index="index"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stack Mode (Collapsed and > 1 item) -->
|
||||
<div v-else class="relative" :style="{ height: stackHeight + 'px' }">
|
||||
<div
|
||||
v-for="(item, index) in stackedItems"
|
||||
:key="index"
|
||||
class="absolute inset-x-0 top-0 transition-all duration-300 ease-in-out cursor-pointer"
|
||||
:style="getStackStyle(index)"
|
||||
@click="onCardClick(index)"
|
||||
>
|
||||
<slot :item="item" :index="index"></slot>
|
||||
|
||||
<!-- Overlay for non-top cards -->
|
||||
<div
|
||||
v-if="index > 0"
|
||||
class="absolute inset-0 bg-white/20 dark:bg-black/20 rounded-[inherit] pointer-events-none"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div v-if="items.length > 1" class="absolute -bottom-2 right-0 flex items-center gap-2 z-[60]">
|
||||
<div class="text-xs font-mono text-gray-500 dark:text-gray-400 mr-2">
|
||||
{{ activeIndex + 1 }} / {{ items.length }}
|
||||
</div>
|
||||
<button
|
||||
class="p-1.5 rounded-full bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-gray-300 transition shadow-sm border border-gray-200 dark:border-zinc-700"
|
||||
title="Previous"
|
||||
@click.stop="prev"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="chevron-left" class="size-5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-full bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-gray-300 transition shadow-sm border border-gray-200 dark:border-zinc-700"
|
||||
title="Next"
|
||||
@click.stop="next"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="items && items.length > 1" class="mt-4 flex justify-center">
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-4 py-1.5 rounded-full bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-xs font-bold text-gray-700 dark:text-gray-300 transition shadow-sm border border-gray-200 dark:border-zinc-700 uppercase tracking-wider"
|
||||
@click="isExpanded = !isExpanded"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="isExpanded ? 'collapse-all' : 'expand-all'" class="size-4" />
|
||||
{{ isExpanded ? "Collapse Stack" : `Show All ${items.length}` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "CardStack",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
maxVisible: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
stackHeight: {
|
||||
type: Number,
|
||||
default: 320,
|
||||
},
|
||||
gridClass: {
|
||||
type: String,
|
||||
default: "grid grid-cols-1 gap-4",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isExpanded: false,
|
||||
activeIndex: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
stackedItems() {
|
||||
// Reorder items so the active item is at index 0
|
||||
const result = [];
|
||||
const count = Math.min(this.items.length, this.maxVisible);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const idx = (this.activeIndex + i) % this.items.length;
|
||||
result.push(this.items[idx]);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
next() {
|
||||
this.activeIndex = (this.activeIndex + 1) % this.items.length;
|
||||
},
|
||||
prev() {
|
||||
this.activeIndex = (this.activeIndex - 1 + this.items.length) % this.items.length;
|
||||
},
|
||||
onCardClick(index) {
|
||||
if (index > 0) {
|
||||
// If clicked a background card, bring it to front
|
||||
this.activeIndex = (this.activeIndex + index) % this.items.length;
|
||||
}
|
||||
},
|
||||
getStackStyle(index) {
|
||||
if (this.isExpanded) return {};
|
||||
|
||||
const offset = 8; // px
|
||||
const scaleReduce = 0.05;
|
||||
|
||||
return {
|
||||
zIndex: 50 - index,
|
||||
transform: `translateY(${index * offset}px) scale(${1 - index * scaleReduce})`,
|
||||
opacity: 1 - index * 0.2,
|
||||
pointerEvents: index === 0 ? "auto" : "auto",
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-stack-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stack-mode {
|
||||
perspective: 1000px;
|
||||
}
|
||||
</style>
|
||||
94
meshchatx/src/frontend/components/ColourPickerDropdown.vue
Normal file
94
meshchatx/src/frontend/components/ColourPickerDropdown.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div
|
||||
v-click-outside="{ handler: onClickOutsideMenu, capture: true }"
|
||||
class="cursor-default relative inline-block text-left"
|
||||
>
|
||||
<!-- menu button -->
|
||||
<div ref="dropdown-button" @click.stop="toggleMenu">
|
||||
<slot>
|
||||
<div
|
||||
class="size-8 border border-gray-300 dark:border-zinc-700 rounded shadow cursor-pointer"
|
||||
:style="{ 'background-color': colour }"
|
||||
></div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- drop down menu -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div v-if="isShowingMenu" class="absolute left-0 z-10 ml-4">
|
||||
<v-color-picker
|
||||
v-model="colourPickerValue"
|
||||
:modes="['hex']"
|
||||
hide-inputs
|
||||
hide-sliders
|
||||
show-swatches
|
||||
></v-color-picker>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ColourPickerDropdown",
|
||||
props: {
|
||||
colour: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["update:colour"],
|
||||
data() {
|
||||
return {
|
||||
isShowingMenu: false,
|
||||
colourPickerValue: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
colour() {
|
||||
// update internal colour picker value when parent changes value of v-model:colour
|
||||
this.colourPickerValue = this.colour;
|
||||
},
|
||||
colourPickerValue() {
|
||||
// get current colour picker value
|
||||
var value = this.colourPickerValue;
|
||||
|
||||
// remove alpha channel from hex colour if present
|
||||
if (value.length === 9) {
|
||||
value = value.substring(0, 7);
|
||||
}
|
||||
|
||||
// fire v-model:colour update event
|
||||
this.$emit("update:colour", value);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleMenu() {
|
||||
if (this.isShowingMenu) {
|
||||
this.hideMenu();
|
||||
} else {
|
||||
this.showMenu();
|
||||
}
|
||||
},
|
||||
showMenu() {
|
||||
this.isShowingMenu = true;
|
||||
},
|
||||
hideMenu() {
|
||||
this.isShowingMenu = false;
|
||||
},
|
||||
onClickOutsideMenu(event) {
|
||||
if (this.isShowingMenu) {
|
||||
event.preventDefault();
|
||||
this.hideMenu();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
101
meshchatx/src/frontend/components/DropDownMenu.vue
Normal file
101
meshchatx/src/frontend/components/DropDownMenu.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div
|
||||
v-click-outside="{ handler: onClickOutsideMenu, capture: true }"
|
||||
class="cursor-default relative inline-block text-left"
|
||||
>
|
||||
<!-- menu button -->
|
||||
<div ref="dropdown-button" @click.stop="toggleMenu">
|
||||
<slot name="button" />
|
||||
</div>
|
||||
|
||||
<!-- drop down menu -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="isShowingMenu"
|
||||
class="overflow-hidden absolute right-0 z-50 mr-4 w-56 rounded-md bg-white dark:bg-zinc-800 shadow-lg border border-gray-200 dark:border-zinc-700 focus:outline-none"
|
||||
:class="[dropdownClass]"
|
||||
@click.stop="hideMenu"
|
||||
>
|
||||
<slot name="items" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "DropDownMenu",
|
||||
data() {
|
||||
return {
|
||||
isShowingMenu: false,
|
||||
dropdownClass: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
toggleMenu() {
|
||||
if (this.isShowingMenu) {
|
||||
this.hideMenu();
|
||||
} else {
|
||||
this.showMenu();
|
||||
}
|
||||
},
|
||||
showMenu() {
|
||||
this.isShowingMenu = true;
|
||||
this.adjustDropdownPosition();
|
||||
},
|
||||
hideMenu() {
|
||||
this.isShowingMenu = false;
|
||||
},
|
||||
onClickOutsideMenu(event) {
|
||||
if (this.isShowingMenu) {
|
||||
event.preventDefault();
|
||||
this.hideMenu();
|
||||
}
|
||||
},
|
||||
adjustDropdownPosition() {
|
||||
this.$nextTick(() => {
|
||||
// find button and dropdown
|
||||
const button = this.$refs["dropdown-button"];
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropdown = button.parentElement?.querySelector(".absolute");
|
||||
if (!dropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
// get bounding box of button
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
|
||||
// calculate how much space is under and above the button
|
||||
const spaceBelowButton = window.innerHeight - buttonRect.bottom;
|
||||
const spaceAboveButton = buttonRect.top;
|
||||
|
||||
// estimate dropdown height (will be measured after render)
|
||||
const estimatedDropdownHeight = 150;
|
||||
|
||||
// calculate if there is enough space available to show dropdown
|
||||
const hasEnoughSpaceAboveButton = spaceAboveButton > estimatedDropdownHeight;
|
||||
const hasEnoughSpaceBelowButton = spaceBelowButton > estimatedDropdownHeight;
|
||||
|
||||
// show dropdown above button
|
||||
if (hasEnoughSpaceAboveButton && !hasEnoughSpaceBelowButton) {
|
||||
this.dropdownClass = "bottom-0 mb-12";
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise fallback to showing dropdown below button
|
||||
this.dropdownClass = "top-0 mt-12";
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
13
meshchatx/src/frontend/components/DropDownMenuItem.vue
Normal file
13
meshchatx/src/frontend/components/DropDownMenuItem.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
class="cursor-pointer flex p-3 space-x-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-700"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "DropDownMenuItem",
|
||||
};
|
||||
</script>
|
||||
14
meshchatx/src/frontend/components/IconButton.vue
Normal file
14
meshchatx/src/frontend/components/IconButton.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-100 hover:bg-gray-100 dark:hover:bg-zinc-800 p-2 rounded-full w-8 h-8 flex items-center justify-center flex-shrink-0 transition-all duration-200"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "IconButton",
|
||||
};
|
||||
</script>
|
||||
95
meshchatx/src/frontend/components/LanguageSelector.vue
Normal file
95
meshchatx/src/frontend/components/LanguageSelector.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="relative rounded-full p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
:title="$t('app.language')"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="translate" class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isDropdownOpen"
|
||||
v-click-outside="closeDropdown"
|
||||
class="absolute right-0 mt-2 w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] overflow-hidden"
|
||||
>
|
||||
<div class="p-2">
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
type="button"
|
||||
class="w-full px-4 py-2 text-left rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors flex items-center justify-between"
|
||||
:class="{
|
||||
'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400':
|
||||
currentLanguage === lang.code,
|
||||
'text-gray-900 dark:text-zinc-100': currentLanguage !== lang.code,
|
||||
}"
|
||||
@click="selectLanguage(lang.code)"
|
||||
>
|
||||
<span class="font-medium">{{ lang.name }}</span>
|
||||
<MaterialDesignIcon v-if="currentLanguage === lang.code" icon-name="check" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "LanguageSelector",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
directives: {
|
||||
"click-outside": {
|
||||
mounted(el, binding) {
|
||||
el.clickOutsideEvent = function (event) {
|
||||
if (!(el === event.target || el.contains(event.target))) {
|
||||
binding.value();
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", el.clickOutsideEvent);
|
||||
},
|
||||
unmounted(el) {
|
||||
document.removeEventListener("click", el.clickOutsideEvent);
|
||||
},
|
||||
},
|
||||
},
|
||||
emits: ["language-change"],
|
||||
data() {
|
||||
return {
|
||||
isDropdownOpen: false,
|
||||
languages: [
|
||||
{ code: "en", name: "English" },
|
||||
{ code: "de", name: "Deutsch" },
|
||||
{ code: "ru", name: "Русский" },
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
currentLanguage() {
|
||||
return this.$i18n.locale;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleDropdown() {
|
||||
this.isDropdownOpen = !this.isDropdownOpen;
|
||||
},
|
||||
closeDropdown() {
|
||||
this.isDropdownOpen = false;
|
||||
},
|
||||
async selectLanguage(langCode) {
|
||||
if (this.currentLanguage === langCode) {
|
||||
this.closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("language-change", langCode);
|
||||
this.closeDropdown();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
36
meshchatx/src/frontend/components/LxmfUserIcon.vue
Normal file
36
meshchatx/src/frontend/components/LxmfUserIcon.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="iconName"
|
||||
class="p-2 rounded"
|
||||
:style="{ color: iconForegroundColour, 'background-color': iconBackgroundColour }"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="iconName" class="size-6" />
|
||||
</div>
|
||||
<div v-else class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded">
|
||||
<MaterialDesignIcon icon-name="account-outline" class="size-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: "LxmfUserIcon",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
iconName: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
iconForegroundColour: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
iconBackgroundColour: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
47
meshchatx/src/frontend/components/MaterialDesignIcon.vue
Normal file
47
meshchatx/src/frontend/components/MaterialDesignIcon.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
:aria-label="iconName"
|
||||
fill="currentColor"
|
||||
style="display: inline-block; vertical-align: middle"
|
||||
>
|
||||
<path :d="iconPath" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as mdi from "@mdi/js";
|
||||
|
||||
export default {
|
||||
name: "MaterialDesignIcon",
|
||||
props: {
|
||||
iconName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
mdiIconName() {
|
||||
// convert icon name from lxmf icon appearance to format expected by the @mdi/js library
|
||||
// e.g: alien-outline -> mdiAlienOutline
|
||||
// https://pictogrammers.github.io/@mdi/font/5.4.55/
|
||||
return (
|
||||
"mdi" +
|
||||
this.iconName
|
||||
.split("-")
|
||||
.map((word) => {
|
||||
// capitalise first letter of each part
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
})
|
||||
.join("")
|
||||
);
|
||||
},
|
||||
iconPath() {
|
||||
// find icon, otherwise fallback to question mark, and if that doesn't exist, show nothing...
|
||||
return mdi[this.mdiIconName] || mdi["mdiProgressQuestion"] || "";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
237
meshchatx/src/frontend/components/NotificationBell.vue
Normal file
237
meshchatx/src/frontend/components/NotificationBell.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="relative rounded-full p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="bell" class="w-6 h-6" />
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute top-0 right-0 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs font-semibold text-white"
|
||||
>
|
||||
{{ unreadCount > 9 ? "9+" : unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isDropdownOpen"
|
||||
v-click-outside="closeDropdown"
|
||||
class="absolute right-0 mt-2 w-80 sm:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[500px] overflow-hidden flex flex-col"
|
||||
>
|
||||
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div v-if="isLoading" class="p-8 text-center">
|
||||
<div class="inline-block animate-spin text-gray-400">
|
||||
<MaterialDesignIcon icon-name="refresh" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading notifications...</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="notifications.length === 0" class="p-8 text-center">
|
||||
<MaterialDesignIcon
|
||||
icon-name="bell-off"
|
||||
class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">No new notifications</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
<button
|
||||
v-for="notification in notifications"
|
||||
:key="notification.destination_hash"
|
||||
type="button"
|
||||
class="w-full p-4 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
|
||||
@click="onNotificationClick(notification)"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
v-if="notification.lxmf_user_icon"
|
||||
class="p-2 rounded-lg"
|
||||
:style="{
|
||||
color: notification.lxmf_user_icon.foreground_colour,
|
||||
'background-color': notification.lxmf_user_icon.background_colour,
|
||||
}"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="notification.lxmf_user_icon.icon_name"
|
||||
class="w-6 h-6"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded-lg"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2 mb-1">
|
||||
<div
|
||||
class="font-semibold text-gray-900 dark:text-white truncate"
|
||||
:title="notification.custom_display_name ?? notification.display_name"
|
||||
>
|
||||
{{ notification.custom_display_name ?? notification.display_name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{{ formatTimeAgo(notification.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
|
||||
:title="
|
||||
notification.latest_message_preview ??
|
||||
notification.latest_message_title ??
|
||||
'No message preview'
|
||||
"
|
||||
>
|
||||
{{
|
||||
notification.latest_message_preview ??
|
||||
notification.latest_message_title ??
|
||||
"No message preview"
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
|
||||
import Utils from "../js/Utils";
|
||||
import WebSocketConnection from "../js/WebSocketConnection";
|
||||
|
||||
export default {
|
||||
name: "NotificationBell",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
directives: {
|
||||
"click-outside": {
|
||||
mounted(el, binding) {
|
||||
el.clickOutsideEvent = function (event) {
|
||||
if (!(el === event.target || el.contains(event.target))) {
|
||||
binding.value();
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", el.clickOutsideEvent);
|
||||
},
|
||||
unmounted(el) {
|
||||
document.removeEventListener("click", el.clickOutsideEvent);
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDropdownOpen: false,
|
||||
isLoading: false,
|
||||
notifications: [],
|
||||
reloadInterval: null,
|
||||
manuallyCleared: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
unreadCount() {
|
||||
if (this.manuallyCleared) {
|
||||
return 0;
|
||||
}
|
||||
return this.notifications.length;
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.reloadInterval) {
|
||||
clearInterval(this.reloadInterval);
|
||||
}
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
},
|
||||
mounted() {
|
||||
this.loadNotifications();
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
this.reloadInterval = setInterval(() => {
|
||||
if (this.isDropdownOpen) {
|
||||
this.loadNotifications();
|
||||
}
|
||||
}, 5000);
|
||||
},
|
||||
methods: {
|
||||
toggleDropdown() {
|
||||
this.isDropdownOpen = !this.isDropdownOpen;
|
||||
if (this.isDropdownOpen) {
|
||||
this.loadNotifications();
|
||||
this.manuallyCleared = true;
|
||||
}
|
||||
},
|
||||
closeDropdown() {
|
||||
this.isDropdownOpen = false;
|
||||
},
|
||||
async loadNotifications() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/lxmf/conversations`, {
|
||||
params: {
|
||||
filter_unread: true,
|
||||
limit: 10,
|
||||
},
|
||||
});
|
||||
const newNotifications = response.data.conversations || [];
|
||||
|
||||
// if we have more notifications than before, show the red dot again
|
||||
if (newNotifications.length > this.notifications.length) {
|
||||
this.manuallyCleared = false;
|
||||
}
|
||||
|
||||
this.notifications = newNotifications;
|
||||
} catch (e) {
|
||||
console.error("Failed to load notifications", e);
|
||||
this.notifications = [];
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
onNotificationClick(notification) {
|
||||
this.closeDropdown();
|
||||
this.$router.push({
|
||||
name: "messages",
|
||||
params: { destinationHash: notification.destination_hash },
|
||||
});
|
||||
},
|
||||
formatTimeAgo(datetimeString) {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
},
|
||||
async onWebsocketMessage(message) {
|
||||
const json = JSON.parse(message.data);
|
||||
if (json.type === "lxmf.delivery") {
|
||||
await this.loadNotifications();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
44
meshchatx/src/frontend/components/SidebarLink.vue
Normal file
44
meshchatx/src/frontend/components/SidebarLink.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<RouterLink v-slot="{ href, navigate, isActive }" :to="to" custom>
|
||||
<a
|
||||
:href="href"
|
||||
type="button"
|
||||
:class="[
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-zinc-700',
|
||||
]"
|
||||
class="w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"
|
||||
@click="handleNavigate($event, navigate)"
|
||||
>
|
||||
<span class="my-auto">
|
||||
<slot name="icon"></slot>
|
||||
</span>
|
||||
<span class="my-auto flex w-full">
|
||||
<slot name="text"></slot>
|
||||
</span>
|
||||
</a>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "SidebarLink",
|
||||
props: {
|
||||
to: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["click"],
|
||||
methods: {
|
||||
handleNavigate(event, navigate) {
|
||||
// emit click event for SidebarLink element
|
||||
this.$emit("click");
|
||||
|
||||
// handle navigation
|
||||
navigate(event);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
122
meshchatx/src/frontend/components/Toast.vue
Normal file
122
meshchatx/src/frontend/components/Toast.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="pointer-events-auto flex items-center p-4 min-w-[300px] max-w-md rounded-xl shadow-lg border backdrop-blur-md transition-all duration-300"
|
||||
:class="toastClass(toast.type)"
|
||||
>
|
||||
<!-- icon -->
|
||||
<div class="mr-3 flex-shrink-0">
|
||||
<MaterialDesignIcon
|
||||
v-if="toast.type === 'success'"
|
||||
icon-name="check-circle"
|
||||
class="h-6 w-6 text-green-500"
|
||||
/>
|
||||
<MaterialDesignIcon
|
||||
v-else-if="toast.type === 'error'"
|
||||
icon-name="alert-circle"
|
||||
class="h-6 w-6 text-red-500"
|
||||
/>
|
||||
<MaterialDesignIcon
|
||||
v-else-if="toast.type === 'warning'"
|
||||
icon-name="alert"
|
||||
class="h-6 w-6 text-amber-500"
|
||||
/>
|
||||
<MaterialDesignIcon v-else icon-name="information" class="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<div class="flex-1 mr-2 text-sm font-medium text-gray-900 dark:text-zinc-100">
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
|
||||
<!-- close button -->
|
||||
<button
|
||||
class="ml-auto text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
|
||||
@click="remove(toast.id)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import GlobalEmitter from "../js/GlobalEmitter";
|
||||
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "Toast",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
toasts: [],
|
||||
counter: 0,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
GlobalEmitter.on("toast", (toast) => {
|
||||
this.add(toast);
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
GlobalEmitter.off("toast");
|
||||
},
|
||||
methods: {
|
||||
add(toast) {
|
||||
const id = this.counter++;
|
||||
const newToast = {
|
||||
id,
|
||||
message: toast.message,
|
||||
type: toast.type || "info",
|
||||
duration: toast.duration || 5000,
|
||||
};
|
||||
this.toasts.push(newToast);
|
||||
|
||||
if (newToast.duration > 0) {
|
||||
setTimeout(() => {
|
||||
this.remove(id);
|
||||
}, newToast.duration);
|
||||
}
|
||||
},
|
||||
remove(id) {
|
||||
const index = this.toasts.findIndex((t) => t.id === id);
|
||||
if (index !== -1) {
|
||||
this.toasts.splice(index, 1);
|
||||
}
|
||||
},
|
||||
toastClass(type) {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return "bg-white/90 dark:bg-zinc-900/90 border-green-500/30";
|
||||
case "error":
|
||||
return "bg-white/90 dark:bg-zinc-900/90 border-red-500/30";
|
||||
case "warning":
|
||||
return "bg-white/90 dark:bg-zinc-900/90 border-amber-500/30";
|
||||
default:
|
||||
return "bg-white/90 dark:bg-zinc-900/90 border-blue-500/30";
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
</style>
|
||||
833
meshchatx/src/frontend/components/about/AboutPage.vue
Normal file
833
meshchatx/src/frontend/components/about/AboutPage.vue
Normal file
@@ -0,0 +1,833 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
|
||||
<div class="space-y-4 w-full max-w-6xl mx-auto">
|
||||
<div v-if="appInfo" class="glass-card">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("about.title") }}
|
||||
</div>
|
||||
<div class="text-3xl font-semibold text-gray-900 dark:text-white">Reticulum MeshChatX</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $t("about.version", { version: appInfo.version }) }} •
|
||||
{{ $t("about.rns_version", { version: appInfo.rns_version }) }} •
|
||||
{{ $t("about.lxmf_version", { version: appInfo.lxmf_version }) }} •
|
||||
{{ $t("about.python_version", { version: appInfo.python_version }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isElectron" class="flex flex-col sm:flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="primary-chip px-4 py-2 text-sm justify-center"
|
||||
@click="relaunch"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="restart" class="w-4 h-4" />
|
||||
{{ $t("common.restart_app") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3 mt-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.config_path") }}</div>
|
||||
<div class="monospace-field break-all">{{ appInfo.reticulum_config_path }}</div>
|
||||
<button
|
||||
v-if="isElectron"
|
||||
type="button"
|
||||
class="secondary-chip mt-2 text-xs"
|
||||
@click="showReticulumConfigFile"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="folder" class="w-4 h-4" />
|
||||
{{ $t("common.reveal") }}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.database_path") }}</div>
|
||||
<div class="monospace-field break-all">{{ appInfo.database_path }}</div>
|
||||
<button
|
||||
v-if="isElectron"
|
||||
type="button"
|
||||
class="secondary-chip mt-2 text-xs"
|
||||
@click="showDatabaseFile"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="database" class="w-4 h-4" />
|
||||
{{ $t("common.reveal") }}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.database_size") }}</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{
|
||||
formatBytes(
|
||||
appInfo.database_files
|
||||
? appInfo.database_files.total_bytes
|
||||
: appInfo.database_file_size
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="appInfo.database_files" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Main {{ formatBytes(appInfo.database_files.main_bytes) }} • WAL
|
||||
{{ formatBytes(appInfo.database_files.wal_bytes) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card space-y-4">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("about.database_health") }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ $t("about.database_health_description") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip px-3 py-2 text-xs"
|
||||
:disabled="databaseActionInProgress || healthLoading"
|
||||
@click="getDatabaseHealth(true)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="refresh" class="w-4 h-4" />
|
||||
{{ $t("common.refresh") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip px-3 py-2 text-xs"
|
||||
:disabled="databaseActionInProgress || healthLoading"
|
||||
@click="vacuumDatabase"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="broom" class="w-4 h-4" />
|
||||
{{ $t("common.vacuum") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary-chip px-3 py-2 text-xs"
|
||||
:disabled="databaseActionInProgress || healthLoading"
|
||||
@click="recoverDatabase"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="shield-sync" class="w-4 h-4" />
|
||||
{{ $t("common.auto_recover") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="databaseActionMessage" class="text-xs text-emerald-600">{{ databaseActionMessage }}</div>
|
||||
<div v-if="databaseActionError" class="text-xs text-red-600">{{ databaseActionError }}</div>
|
||||
<div v-if="healthLoading" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $t("about.running_checks") }}
|
||||
</div>
|
||||
<div
|
||||
v-if="databaseHealth"
|
||||
class="grid gap-3 sm:grid-cols-3 text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.integrity") }}</div>
|
||||
<div class="metric-value">{{ databaseHealth.quick_check }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.journal_mode") }}</div>
|
||||
<div class="metric-value">{{ databaseHealth.journal_mode }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.wal_autocheckpoint") }}</div>
|
||||
<div class="metric-value">
|
||||
{{
|
||||
databaseHealth.wal_autocheckpoint !== null &&
|
||||
databaseHealth.wal_autocheckpoint !== undefined
|
||||
? databaseHealth.wal_autocheckpoint
|
||||
: "—"
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.page_size") }}</div>
|
||||
<div class="metric-value">{{ formatBytes(databaseHealth.page_size) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.pages_free") }}</div>
|
||||
<div class="metric-value">
|
||||
{{ formatNumber(databaseHealth.page_count) }} /
|
||||
{{ formatNumber(databaseHealth.freelist_pages) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.free_space_estimate") }}</div>
|
||||
<div class="metric-value">{{ formatBytes(databaseHealth.estimated_free_bytes) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!healthLoading" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Health data will appear after the first refresh.
|
||||
</div>
|
||||
<div
|
||||
v-if="databaseRecoveryActions.length"
|
||||
class="text-xs text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-800 pt-3"
|
||||
>
|
||||
<div class="font-semibold text-gray-800 dark:text-gray-200 mb-1">Last recovery steps</div>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li v-for="(action, index) in databaseRecoveryActions" :key="index">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ action.step }}:</span>
|
||||
<span class="ml-1">{{ formatRecoveryResult(action.result) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-800 pt-3 space-y-3">
|
||||
<div class="font-semibold text-gray-900 dark:text-white">Backups</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip px-3 py-2 text-xs"
|
||||
:disabled="backupInProgress"
|
||||
@click="backupDatabase"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="content-save" class="w-4 h-4" />
|
||||
Download Backup
|
||||
</button>
|
||||
<div v-if="backupMessage" class="text-xs text-emerald-600">{{ backupMessage }}</div>
|
||||
<div v-if="backupError" class="text-xs text-red-600">{{ backupError }}</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white pt-2">Restore</div>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<input type="file" accept=".zip,.db" class="file-input" @change="onRestoreFileChange" />
|
||||
<button
|
||||
type="button"
|
||||
class="primary-chip px-3 py-2 text-xs"
|
||||
:disabled="restoreInProgress"
|
||||
@click="restoreDatabase"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="database-sync" class="w-4 h-4" />
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="restoreFileName" class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Selected: {{ restoreFileName }}
|
||||
</div>
|
||||
<div v-if="restoreMessage" class="text-xs text-emerald-600">{{ restoreMessage }}</div>
|
||||
<div v-if="restoreError" class="text-xs text-red-600">{{ restoreError }}</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-800 pt-3 space-y-3">
|
||||
<div class="font-semibold text-gray-900 dark:text-white">Identity Backup & Restore</div>
|
||||
<div class="text-xs text-red-600">
|
||||
Never share this identity. It grants full control. Clear your clipboard after copying.
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip px-3 py-2 text-xs"
|
||||
@click="downloadIdentityFile"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="content-save" class="w-4 h-4" />
|
||||
Download Identity File
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip px-3 py-2 text-xs"
|
||||
@click="copyIdentityBase32"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4" />
|
||||
Copy Base32 Identity
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="identityBackupMessage" class="text-xs text-emerald-600">
|
||||
{{ identityBackupMessage }}
|
||||
</div>
|
||||
<div v-if="identityBackupError" class="text-xs text-red-600">{{ identityBackupError }}</div>
|
||||
<div v-if="identityBase32Message" class="text-xs text-emerald-600">
|
||||
{{ identityBase32Message }}
|
||||
</div>
|
||||
<div v-if="identityBase32Error" class="text-xs text-red-600">{{ identityBase32Error }}</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white pt-2">Restore from file</div>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<input
|
||||
type="file"
|
||||
accept=".identity,.bin,.key"
|
||||
class="file-input"
|
||||
@change="onIdentityRestoreFileChange"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="primary-chip px-3 py-2 text-xs"
|
||||
:disabled="identityRestoreInProgress"
|
||||
@click="restoreIdentityFile"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="database-sync" class="w-4 h-4" />
|
||||
Restore Identity
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="identityRestoreFileName" class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Selected: {{ identityRestoreFileName }}
|
||||
</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white pt-2">Restore from base32</div>
|
||||
<textarea v-model="identityRestoreBase32" rows="3" class="input-field"></textarea>
|
||||
<button
|
||||
type="button"
|
||||
class="primary-chip px-3 py-2 text-xs"
|
||||
:disabled="identityRestoreInProgress"
|
||||
@click="restoreIdentityBase32"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="database-sync" class="w-4 h-4" />
|
||||
Restore Identity
|
||||
</button>
|
||||
<div v-if="identityRestoreMessage" class="text-xs text-emerald-600">
|
||||
{{ identityRestoreMessage }}
|
||||
</div>
|
||||
<div v-if="identityRestoreError" class="text-xs text-red-600">
|
||||
{{ identityRestoreError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div v-if="appInfo?.memory_usage" class="glass-card space-y-3">
|
||||
<header class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="chip" class="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("about.system_resources") }}
|
||||
</div>
|
||||
<div class="text-xs text-emerald-500 flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
{{ $t("about.live") }}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="metric-row">
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.memory_rss") }}</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.rss) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.virtual_memory") }}</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.vms) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="appInfo?.network_stats" class="glass-card space-y-3">
|
||||
<header class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="access-point-network" class="w-5 h-5 text-purple-500" />
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("about.network_stats") }}
|
||||
</div>
|
||||
<div class="text-xs text-emerald-500 flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
{{ $t("about.live") }}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="metric-row">
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.sent") }}</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.network_stats.bytes_sent) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.received") }}</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.network_stats.bytes_recv) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.packets_sent") }}</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_sent) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.packets_received") }}</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_recv) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="appInfo?.reticulum_stats" class="glass-card space-y-3">
|
||||
<header class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="diagram-projector" class="w-5 h-5 text-indigo-500" />
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("about.reticulum_stats") }}
|
||||
</div>
|
||||
<div class="text-xs text-emerald-500 flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
{{ $t("about.live") }}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="metric-grid">
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.total_paths") }}</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.total_paths) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.announces_per_second") }}</div>
|
||||
<div class="metric-value">
|
||||
{{ formatNumber(appInfo.reticulum_stats.announces_per_second) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.announces_per_minute") }}</div>
|
||||
<div class="metric-value">
|
||||
{{ formatNumber(appInfo.reticulum_stats.announces_per_minute) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">{{ $t("about.announces_per_hour") }}</div>
|
||||
<div class="metric-value">
|
||||
{{ formatNumber(appInfo.reticulum_stats.announces_per_hour) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="appInfo?.download_stats" class="glass-card space-y-3">
|
||||
<header class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="download" class="w-5 h-5 text-sky-500" />
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("about.download_activity") }}
|
||||
</div>
|
||||
<div class="text-xs text-emerald-500 flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
{{ $t("about.live") }}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="metric-value">
|
||||
<span v-if="appInfo.download_stats.avg_download_speed_bps !== null">
|
||||
{{ formatBytesPerSecond(appInfo.download_stats.avg_download_speed_bps) }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-500">{{ $t("about.no_downloads_yet") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="appInfo" class="glass-card space-y-3">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("about.runtime_status") }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<span :class="statusPillClass(!appInfo.is_connected_to_shared_instance)">
|
||||
<MaterialDesignIcon icon-name="server" class="w-4 h-4" />
|
||||
{{
|
||||
appInfo.is_connected_to_shared_instance
|
||||
? $t("about.shared_instance")
|
||||
: $t("about.standalone_instance")
|
||||
}}
|
||||
</span>
|
||||
<span :class="statusPillClass(appInfo.is_transport_enabled)">
|
||||
<MaterialDesignIcon icon-name="transit-connection" class="w-4 h-4" />
|
||||
{{
|
||||
appInfo.is_transport_enabled
|
||||
? $t("about.transport_enabled")
|
||||
: $t("about.transport_disabled")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="config" class="glass-card space-y-4">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("about.identity_addresses") }}
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="address-card">
|
||||
<div class="glass-label">{{ $t("app.identity_hash") }}</div>
|
||||
<div class="monospace-field break-all">{{ config.identity_hash }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip mt-3 text-xs"
|
||||
@click="copyValue(config.identity_hash, $t('app.identity_hash'))"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4" />
|
||||
{{ $t("app.copy") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="address-card">
|
||||
<div class="glass-label">{{ $t("app.lxmf_address") }}</div>
|
||||
<div class="monospace-field break-all">{{ config.lxmf_address_hash }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip mt-3 text-xs"
|
||||
@click="copyValue(config.lxmf_address_hash, $t('app.lxmf_address'))"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account-network" class="w-4 h-4" />
|
||||
{{ $t("app.copy") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="address-card">
|
||||
<div class="glass-label">{{ $t("app.propagation_node") }}</div>
|
||||
<div class="monospace-field break-all">
|
||||
{{ config.lxmf_local_propagation_node_address_hash || "—" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="address-card">
|
||||
<div class="glass-label">{{ $t("about.telephone_address") }}</div>
|
||||
<div class="monospace-field break-all">{{ config.telephone_address_hash || "—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Utils from "../../js/Utils";
|
||||
import ElectronUtils from "../../js/ElectronUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
export default {
|
||||
name: "AboutPage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
appInfo: null,
|
||||
config: null,
|
||||
updateInterval: null,
|
||||
healthInterval: null,
|
||||
databaseHealth: null,
|
||||
databaseRecoveryActions: [],
|
||||
databaseActionMessage: "",
|
||||
databaseActionError: "",
|
||||
databaseActionInProgress: false,
|
||||
healthLoading: false,
|
||||
backupInProgress: false,
|
||||
backupMessage: "",
|
||||
backupError: "",
|
||||
restoreInProgress: false,
|
||||
restoreMessage: "",
|
||||
restoreError: "",
|
||||
restoreFileName: "",
|
||||
restoreFile: null,
|
||||
identityBackupMessage: "",
|
||||
identityBackupError: "",
|
||||
identityBase32: "",
|
||||
identityBase32Message: "",
|
||||
identityBase32Error: "",
|
||||
identityRestoreInProgress: false,
|
||||
identityRestoreMessage: "",
|
||||
identityRestoreError: "",
|
||||
identityRestoreFileName: "",
|
||||
identityRestoreFile: null,
|
||||
identityRestoreBase32: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isElectron() {
|
||||
return ElectronUtils.isElectron();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getAppInfo();
|
||||
this.getConfig();
|
||||
this.getDatabaseHealth();
|
||||
// Update stats every 5 seconds
|
||||
this.updateInterval = setInterval(() => {
|
||||
this.getAppInfo();
|
||||
}, 5000);
|
||||
this.healthInterval = setInterval(() => {
|
||||
this.getDatabaseHealth();
|
||||
}, 30000);
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
}
|
||||
if (this.healthInterval) {
|
||||
clearInterval(this.healthInterval);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async getAppInfo() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/app/info");
|
||||
this.appInfo = response.data.app_info;
|
||||
} catch (e) {
|
||||
// do nothing if failed to load app info
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async getDatabaseHealth(showMessage = false) {
|
||||
this.healthLoading = true;
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/database/health");
|
||||
this.databaseHealth = response.data.database;
|
||||
if (showMessage) {
|
||||
this.databaseActionMessage = "Database health refreshed";
|
||||
}
|
||||
this.databaseActionError = "";
|
||||
} catch {
|
||||
this.databaseActionError = "Failed to load database health";
|
||||
} finally {
|
||||
this.healthLoading = false;
|
||||
}
|
||||
},
|
||||
async vacuumDatabase() {
|
||||
if (this.databaseActionInProgress) {
|
||||
return;
|
||||
}
|
||||
this.databaseActionInProgress = true;
|
||||
this.databaseActionMessage = "";
|
||||
this.databaseActionError = "";
|
||||
this.databaseRecoveryActions = [];
|
||||
try {
|
||||
const response = await window.axios.post("/api/v1/database/vacuum");
|
||||
if (response.data.database?.health) {
|
||||
this.databaseHealth = response.data.database.health;
|
||||
}
|
||||
this.databaseActionMessage = response.data.message || "Database vacuum completed";
|
||||
} catch (e) {
|
||||
this.databaseActionError = "Vacuum failed";
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.databaseActionInProgress = false;
|
||||
}
|
||||
},
|
||||
async backupDatabase() {
|
||||
if (this.backupInProgress) {
|
||||
return;
|
||||
}
|
||||
this.backupInProgress = true;
|
||||
this.backupMessage = "";
|
||||
this.backupError = "";
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/database/backup/download", {
|
||||
responseType: "blob",
|
||||
});
|
||||
const blob = new Blob([response.data], { type: "application/zip" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
const filename =
|
||||
response.headers["content-disposition"]?.split("filename=")?.[1]?.replace(/"/g, "") ||
|
||||
"meshchatx-backup.zip";
|
||||
link.setAttribute("download", filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
this.backupMessage = "Backup downloaded";
|
||||
await this.getDatabaseHealth();
|
||||
} catch (e) {
|
||||
this.backupError = "Backup failed";
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.backupInProgress = false;
|
||||
}
|
||||
},
|
||||
async restoreDatabase() {
|
||||
if (this.restoreInProgress) {
|
||||
return;
|
||||
}
|
||||
if (!this.restoreFile) {
|
||||
this.restoreError = "Select a backup file to restore.";
|
||||
return;
|
||||
}
|
||||
this.restoreInProgress = true;
|
||||
this.restoreMessage = "";
|
||||
this.restoreError = "";
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", this.restoreFile);
|
||||
const response = await window.axios.post("/api/v1/database/restore", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
this.restoreMessage = response.data.message || "Database restored";
|
||||
this.databaseHealth = response.data.database?.health || this.databaseHealth;
|
||||
this.databaseRecoveryActions = response.data.database?.actions || this.databaseRecoveryActions;
|
||||
await this.getDatabaseHealth();
|
||||
} catch (e) {
|
||||
this.restoreError = "Restore failed";
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.restoreInProgress = false;
|
||||
}
|
||||
},
|
||||
async recoverDatabase() {
|
||||
if (this.databaseActionInProgress) {
|
||||
return;
|
||||
}
|
||||
this.databaseActionInProgress = true;
|
||||
this.databaseActionMessage = "";
|
||||
this.databaseActionError = "";
|
||||
try {
|
||||
const response = await window.axios.post("/api/v1/database/recover");
|
||||
if (response.data.database?.health) {
|
||||
this.databaseHealth = response.data.database.health;
|
||||
}
|
||||
this.databaseRecoveryActions = response.data.database?.actions || [];
|
||||
this.databaseActionMessage = response.data.message || "Database recovery completed";
|
||||
} catch (e) {
|
||||
this.databaseActionError = "Recovery failed";
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.databaseActionInProgress = false;
|
||||
}
|
||||
},
|
||||
async getConfig() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/config");
|
||||
this.config = response.data.config;
|
||||
} catch (e) {
|
||||
// do nothing if failed to load config
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async copyValue(value, label) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
ToastUtils.success(`${label} copied to clipboard`);
|
||||
} catch {
|
||||
ToastUtils.error(`Failed to copy ${label}`);
|
||||
}
|
||||
},
|
||||
relaunch() {
|
||||
ElectronUtils.relaunch();
|
||||
},
|
||||
showReticulumConfigFile() {
|
||||
const reticulumConfigPath = this.appInfo.reticulum_config_path;
|
||||
if (reticulumConfigPath) {
|
||||
ElectronUtils.showPathInFolder(reticulumConfigPath);
|
||||
}
|
||||
},
|
||||
showDatabaseFile() {
|
||||
const databasePath = this.appInfo.database_path;
|
||||
if (databasePath) {
|
||||
ElectronUtils.showPathInFolder(databasePath);
|
||||
}
|
||||
},
|
||||
formatBytes: function (bytes) {
|
||||
return Utils.formatBytes(bytes);
|
||||
},
|
||||
formatNumber: function (num) {
|
||||
return Utils.formatNumber(num);
|
||||
},
|
||||
formatBytesPerSecond: function (bytesPerSecond) {
|
||||
return Utils.formatBytesPerSecond(bytesPerSecond);
|
||||
},
|
||||
onRestoreFileChange(event) {
|
||||
const files = event.target.files;
|
||||
if (files && files[0]) {
|
||||
this.restoreFile = files[0];
|
||||
this.restoreFileName = files[0].name;
|
||||
this.restoreError = "";
|
||||
this.restoreMessage = "";
|
||||
}
|
||||
},
|
||||
async downloadIdentityFile() {
|
||||
this.identityBackupMessage = "";
|
||||
this.identityBackupError = "";
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/identity/backup/download", {
|
||||
responseType: "blob",
|
||||
});
|
||||
const blob = new Blob([response.data], { type: "application/octet-stream" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", "identity");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
this.identityBackupMessage = "Identity downloaded. Keep it secret.";
|
||||
} catch (e) {
|
||||
this.identityBackupError = "Failed to download identity";
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async copyIdentityBase32() {
|
||||
this.identityBase32Message = "";
|
||||
this.identityBase32Error = "";
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/identity/backup/base32");
|
||||
this.identityBase32 = response.data.identity_base32 || "";
|
||||
if (!this.identityBase32) {
|
||||
this.identityBase32Error = "No identity available";
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(this.identityBase32);
|
||||
this.identityBase32Message = "Identity copied. Clear your clipboard after use.";
|
||||
} catch (e) {
|
||||
this.identityBase32Error = "Failed to copy identity";
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
onIdentityRestoreFileChange(event) {
|
||||
const files = event.target.files;
|
||||
if (files && files[0]) {
|
||||
this.identityRestoreFile = files[0];
|
||||
this.identityRestoreFileName = files[0].name;
|
||||
this.identityRestoreError = "";
|
||||
this.identityRestoreMessage = "";
|
||||
}
|
||||
},
|
||||
async restoreIdentityFile() {
|
||||
if (this.identityRestoreInProgress) {
|
||||
return;
|
||||
}
|
||||
if (!this.identityRestoreFile) {
|
||||
this.identityRestoreError = "Select an identity file to restore.";
|
||||
return;
|
||||
}
|
||||
this.identityRestoreInProgress = true;
|
||||
this.identityRestoreMessage = "";
|
||||
this.identityRestoreError = "";
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", this.identityRestoreFile);
|
||||
const response = await window.axios.post("/api/v1/identity/restore", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
|
||||
} catch (e) {
|
||||
this.identityRestoreError = "Identity restore failed";
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.identityRestoreInProgress = false;
|
||||
}
|
||||
},
|
||||
async restoreIdentityBase32() {
|
||||
if (this.identityRestoreInProgress) {
|
||||
return;
|
||||
}
|
||||
if (!this.identityRestoreBase32) {
|
||||
this.identityRestoreError = "Provide a base32 key to restore.";
|
||||
return;
|
||||
}
|
||||
this.identityRestoreInProgress = true;
|
||||
this.identityRestoreMessage = "";
|
||||
this.identityRestoreError = "";
|
||||
try {
|
||||
const response = await window.axios.post("/api/v1/identity/restore", {
|
||||
base32: this.identityRestoreBase32.trim(),
|
||||
});
|
||||
this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
|
||||
} catch (e) {
|
||||
this.identityRestoreError = "Identity restore failed";
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.identityRestoreInProgress = false;
|
||||
}
|
||||
},
|
||||
formatRecoveryResult(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return "—";
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(", ");
|
||||
}
|
||||
return value;
|
||||
},
|
||||
statusPillClass(isGood) {
|
||||
return isGood
|
||||
? "inline-flex items-center gap-1 rounded-full bg-emerald-100 text-emerald-700 px-3 py-1 text-xs font-semibold"
|
||||
: "inline-flex items-center gap-1 rounded-full bg-orange-100 text-orange-700 px-3 py-1 text-xs font-semibold";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
387
meshchatx/src/frontend/components/archives/ArchivesPage.vue
Normal file
387
meshchatx/src/frontend/components/archives/ArchivesPage.vue
Normal file
@@ -0,0 +1,387 @@
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
|
||||
<!-- header -->
|
||||
<div
|
||||
class="flex items-center px-4 py-4 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-sm"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<MaterialDesignIcon icon-name="archive" class="size-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{{ $t("app.archives") }}</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t("archives.description") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2 sm:gap-4">
|
||||
<div class="relative w-32 sm:w-64 md:w-80">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
:placeholder="$t('archives.search_placeholder')"
|
||||
@input="onSearchInput"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="p-2 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||
:title="$t('common.refresh')"
|
||||
@click="getArchives"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="refresh" class="size-6" :class="{ 'animate-spin': isLoading }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-6">
|
||||
<div v-if="isLoading && archives.length === 0" class="flex flex-col items-center justify-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ $t("archives.loading") }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="groupedArchives.length === 0"
|
||||
class="flex flex-col items-center justify-center h-64 text-center"
|
||||
>
|
||||
<div class="p-4 bg-gray-100 dark:bg-zinc-800 rounded-full mb-4 text-gray-400 dark:text-zinc-600">
|
||||
<MaterialDesignIcon icon-name="archive-off" class="size-12" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ $t("archives.no_archives_found") }}
|
||||
</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
|
||||
{{ searchQuery ? $t("archives.adjust_filters") : $t("archives.browse_to_archive") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div v-for="group in groupedArchives" :key="group.destination_hash" class="relative">
|
||||
<div class="sticky top-6">
|
||||
<div
|
||||
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-lg overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="p-5 border-b border-gray-100 dark:border-zinc-800 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-zinc-800 dark:to-zinc-800/50"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span
|
||||
class="text-xs font-bold px-3 py-1.5 bg-blue-500 dark:bg-blue-600 text-white rounded-full uppercase tracking-wider shadow-sm"
|
||||
>
|
||||
{{ group.archives.length }}
|
||||
{{ group.archives.length === 1 ? $t("archives.page") : $t("archives.pages") }}
|
||||
</span>
|
||||
</div>
|
||||
<h4
|
||||
class="text-base font-bold text-gray-900 dark:text-white mb-1 truncate"
|
||||
:title="group.node_name"
|
||||
>
|
||||
{{ group.node_name }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 font-mono truncate">
|
||||
{{ group.destination_hash.substring(0, 16) }}...
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-5 pb-6">
|
||||
<CardStack :items="group.archives" :max-visible="3">
|
||||
<template #default="{ item: archive }">
|
||||
<div
|
||||
class="stacked-card bg-white dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-lg p-4 h-full hover:shadow-xl transition-all duration-200 cursor-pointer group"
|
||||
@click="viewArchive(archive)"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm font-semibold text-gray-900 dark:text-gray-100 font-mono truncate mb-1 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
|
||||
:title="archive.page_path || '/'"
|
||||
>
|
||||
{{ archive.page_path || "/" }}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="clock-outline" class="size-3" />
|
||||
<span>{{ formatDate(archive.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-shrink-0">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-blue-500 dark:bg-blue-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
class="text-xs text-gray-700 dark:text-gray-300 line-clamp-5 micron-preview leading-relaxed"
|
||||
v-html="renderPreview(archive)"
|
||||
></div>
|
||||
<div
|
||||
class="mt-3 pt-3 border-t border-gray-100 dark:border-zinc-700 flex items-center justify-between"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="tag" class="size-3" />
|
||||
<span class="font-mono">{{ archive.hash.substring(0, 8) }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-xs font-medium text-blue-600 dark:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1"
|
||||
>
|
||||
{{ $t("archives.view") }}
|
||||
<MaterialDesignIcon icon-name="arrow-right" class="size-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardStack>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="archives.length > 0" class="mt-8 mb-4 flex items-center justify-between">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
$t("archives.showing_range", {
|
||||
start: pagination.total_count > 0 ? (pagination.page - 1) * pagination.limit + 1 : 0,
|
||||
end: Math.min(pagination.page * pagination.limit, pagination.total_count),
|
||||
total: pagination.total_count,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
:disabled="pagination.page <= 1 || isLoading"
|
||||
class="p-2 rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-700 dark:text-gray-300 disabled:opacity-50 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
@click="changePage(pagination.page - 1)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="chevron-left" class="size-5" />
|
||||
</button>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white px-4">
|
||||
{{
|
||||
$t("archives.page_of", {
|
||||
page: pagination.page,
|
||||
total_pages: pagination.total_pages,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
:disabled="pagination.page >= pagination.total_pages || isLoading"
|
||||
class="p-2 rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-700 dark:text-gray-300 disabled:opacity-50 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
@click="changePage(pagination.page + 1)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import Utils from "../../js/Utils";
|
||||
import MicronParser from "micron-parser";
|
||||
import CardStack from "../CardStack.vue";
|
||||
|
||||
export default {
|
||||
name: "ArchivesPage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
CardStack,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
archives: [],
|
||||
searchQuery: "",
|
||||
isLoading: false,
|
||||
searchTimeout: null,
|
||||
muParser: new MicronParser(),
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 15,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
groupedArchives() {
|
||||
const groups = {};
|
||||
|
||||
for (const archive of this.archives) {
|
||||
const hash = archive.destination_hash;
|
||||
if (!groups[hash]) {
|
||||
groups[hash] = {
|
||||
destination_hash: hash,
|
||||
node_name: archive.node_name,
|
||||
archives: [],
|
||||
};
|
||||
}
|
||||
groups[hash].archives.push(archive);
|
||||
}
|
||||
|
||||
// sort each group by date
|
||||
const grouped = Object.values(groups).map((group) => ({
|
||||
...group,
|
||||
archives: group.archives.sort((a, b) => {
|
||||
const dateA = new Date(a.created_at);
|
||||
const dateB = new Date(b.created_at);
|
||||
return dateB - dateA;
|
||||
}),
|
||||
}));
|
||||
|
||||
// sort groups by the date of their most recent archive
|
||||
return grouped.sort((a, b) => {
|
||||
const dateA = new Date(a.archives[0].created_at);
|
||||
const dateB = new Date(b.archives[0].created_at);
|
||||
return dateB - dateA;
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getArchives();
|
||||
},
|
||||
methods: {
|
||||
async getArchives() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/nomadnet/archives", {
|
||||
params: {
|
||||
q: this.searchQuery,
|
||||
page: this.pagination.page,
|
||||
limit: this.pagination.limit,
|
||||
},
|
||||
});
|
||||
this.archives = response.data.archives;
|
||||
this.pagination = response.data.pagination;
|
||||
} catch (e) {
|
||||
console.error("Failed to load archives:", e);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
onSearchInput() {
|
||||
this.pagination.page = 1; // reset to first page on search
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.getArchives();
|
||||
}, 300);
|
||||
},
|
||||
async changePage(page) {
|
||||
this.pagination.page = page;
|
||||
await this.getArchives();
|
||||
// scroll to top of content
|
||||
const contentElement = document.querySelector(".overflow-y-auto");
|
||||
if (contentElement) contentElement.scrollTop = 0;
|
||||
},
|
||||
viewArchive(archive) {
|
||||
this.$router.push({
|
||||
name: "nomadnetwork",
|
||||
params: { destinationHash: archive.destination_hash },
|
||||
query: {
|
||||
path: archive.page_path,
|
||||
archive_id: archive.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
formatDate(dateStr) {
|
||||
return Utils.formatTimeAgo(dateStr);
|
||||
},
|
||||
renderPreview(archive) {
|
||||
if (!archive.content) return "";
|
||||
|
||||
// limit content for preview
|
||||
const previewContent = archive.content.substring(0, 500);
|
||||
|
||||
// convert micron to html if it looks like micron or ends with .mu
|
||||
if (archive.page_path?.endsWith(".mu") || archive.content.includes("`")) {
|
||||
try {
|
||||
return this.muParser.convertMicronToHtml(previewContent);
|
||||
} catch {
|
||||
return previewContent;
|
||||
}
|
||||
}
|
||||
|
||||
return previewContent;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-5 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stacked-card {
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.stacked-card:hover {
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .stacked-card {
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.3),
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.dark .stacked-card:hover {
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.5),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.micron-preview {
|
||||
font-family:
|
||||
Roboto Mono Nerd Font,
|
||||
monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.micron-preview) a {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:deep(.micron-preview) p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
:deep(.micron-preview) h1,
|
||||
:deep(.micron-preview) h2,
|
||||
:deep(.micron-preview) h3,
|
||||
:deep(.micron-preview) h4 {
|
||||
margin: 0.5rem 0 0.25rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
152
meshchatx/src/frontend/components/auth/AuthPage.vue
Normal file
152
meshchatx/src/frontend/components/auth/AuthPage.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="h-screen w-full flex items-center justify-center bg-slate-50 dark:bg-zinc-950">
|
||||
<div class="w-full max-w-md p-8">
|
||||
<div
|
||||
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-lg border border-gray-200 dark:border-zinc-800 p-8"
|
||||
>
|
||||
<div class="text-center mb-8">
|
||||
<img class="w-16 h-16 mx-auto mb-4" src="/assets/images/logo-chat-bubble.png" />
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100 mb-2">
|
||||
{{ isSetup ? "Initial Setup" : "Authentication Required" }}
|
||||
</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-zinc-400">
|
||||
{{
|
||||
isSetup
|
||||
? "Set an admin password to secure your MeshChat instance"
|
||||
: "Please enter your password to continue"
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<p v-if="isSetup" class="mt-2 text-xs text-gray-500 dark:text-zinc-500">
|
||||
Password must be at least 8 characters long
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isSetup">
|
||||
<label
|
||||
for="confirmPassword"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Confirm password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-red-800 dark:text-red-200">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading || (isSetup && password !== confirmPassword)"
|
||||
class="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
<span v-if="isLoading">Processing...</span>
|
||||
<span v-else>{{ isSetup ? "Set Password" : "Login" }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "AuthPage",
|
||||
data() {
|
||||
return {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
error: "",
|
||||
isLoading: false,
|
||||
isSetup: false,
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
await this.checkAuthStatus();
|
||||
},
|
||||
methods: {
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/auth/status");
|
||||
const status = response.data;
|
||||
|
||||
if (!status.auth_enabled) {
|
||||
this.$router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.authenticated) {
|
||||
this.$router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSetup = !status.password_set;
|
||||
} catch (e) {
|
||||
console.error("Failed to check auth status:", e);
|
||||
this.error = "Failed to check authentication status";
|
||||
}
|
||||
},
|
||||
async handleSubmit() {
|
||||
this.error = "";
|
||||
|
||||
if (this.isSetup) {
|
||||
if (this.password !== this.confirmPassword) {
|
||||
this.error = "Passwords do not match";
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.password.length < 8) {
|
||||
this.error = "Password must be at least 8 characters long";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
const endpoint = this.isSetup ? "/api/v1/auth/setup" : "/api/v1/auth/login";
|
||||
await window.axios.post(endpoint, {
|
||||
password: this.password,
|
||||
});
|
||||
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
this.error = e.response?.data?.error || "Authentication failed";
|
||||
this.password = "";
|
||||
this.confirmPassword = "";
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
263
meshchatx/src/frontend/components/blocked/BlockedPage.vue
Normal file
263
meshchatx/src/frontend/components/blocked/BlockedPage.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
|
||||
<div
|
||||
class="flex items-center px-4 py-4 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-sm"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
|
||||
<MaterialDesignIcon icon-name="block-helper" class="size-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">Blocked</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Manage blocked users and nodes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2 sm:gap-4">
|
||||
<div class="relative w-32 sm:w-64 md:w-80">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="Search by hash or display name..."
|
||||
@input="onSearchInput"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="p-2 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||
title="Refresh"
|
||||
@click="loadBlockedDestinations"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="refresh" class="size-6" :class="{ 'animate-spin': isLoading }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-6">
|
||||
<div v-if="isLoading && blockedItems.length === 0" class="flex flex-col items-center justify-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading blocked items...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredBlockedItems.length === 0"
|
||||
class="flex flex-col items-center justify-center h-64 text-center"
|
||||
>
|
||||
<div class="p-4 bg-gray-100 dark:bg-zinc-800 rounded-full mb-4 text-gray-400 dark:text-zinc-600">
|
||||
<MaterialDesignIcon icon-name="check-circle" class="size-12" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">No blocked items</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
|
||||
{{
|
||||
searchQuery
|
||||
? "No blocked items match your search."
|
||||
: "You haven't blocked any users or nodes yet."
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="item in filteredBlockedItems"
|
||||
:key="item.destination_hash"
|
||||
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-lg overflow-hidden"
|
||||
>
|
||||
<div class="p-5">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg flex-shrink-0">
|
||||
<MaterialDesignIcon
|
||||
icon-name="account-off"
|
||||
class="size-5 text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h4
|
||||
class="text-base font-semibold text-gray-900 dark:text-white break-words"
|
||||
:title="item.display_name"
|
||||
>
|
||||
{{ item.display_name || "Unknown" }}
|
||||
</h4>
|
||||
<span
|
||||
v-if="item.is_node"
|
||||
class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded"
|
||||
>
|
||||
Node
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded"
|
||||
>
|
||||
User
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-gray-500 dark:text-gray-400 font-mono break-all mt-1"
|
||||
:title="item.destination_hash"
|
||||
>
|
||||
{{ item.destination_hash }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Blocked {{ formatTimeAgo(item.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors font-medium"
|
||||
@click="onUnblock(item)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="check-circle" class="size-5" />
|
||||
<span>Unblock</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import Utils from "../../js/Utils";
|
||||
|
||||
export default {
|
||||
name: "BlockedPage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
blockedItems: [],
|
||||
isLoading: false,
|
||||
searchQuery: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredBlockedItems() {
|
||||
if (!this.searchQuery.trim()) {
|
||||
return this.blockedItems;
|
||||
}
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
return this.blockedItems.filter((item) => {
|
||||
const matchesHash = item.destination_hash.toLowerCase().includes(query);
|
||||
const matchesDisplayName = (item.display_name || "").toLowerCase().includes(query);
|
||||
return matchesHash || matchesDisplayName;
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadBlockedDestinations();
|
||||
},
|
||||
methods: {
|
||||
async loadBlockedDestinations() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/blocked-destinations");
|
||||
const blockedHashes = response.data.blocked_destinations || [];
|
||||
|
||||
const items = await Promise.all(
|
||||
blockedHashes.map(async (blocked) => {
|
||||
let displayName = "Unknown";
|
||||
let isNode = false;
|
||||
|
||||
try {
|
||||
const nodeAnnounceResponse = await window.axios.get("/api/v1/announces", {
|
||||
params: {
|
||||
aspect: "nomadnetwork.node",
|
||||
identity_hash: blocked.destination_hash,
|
||||
include_blocked: true,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (nodeAnnounceResponse.data.announces && nodeAnnounceResponse.data.announces.length > 0) {
|
||||
const announce = nodeAnnounceResponse.data.announces[0];
|
||||
displayName = announce.display_name || "Unknown";
|
||||
isNode = true;
|
||||
} else {
|
||||
const announceResponse = await window.axios.get("/api/v1/announces", {
|
||||
params: {
|
||||
identity_hash: blocked.destination_hash,
|
||||
include_blocked: true,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (announceResponse.data.announces && announceResponse.data.announces.length > 0) {
|
||||
const announce = announceResponse.data.announces[0];
|
||||
displayName = announce.display_name || "Unknown";
|
||||
isNode = announce.aspect === "nomadnetwork.node";
|
||||
} else {
|
||||
const lxmfResponse = await window.axios.get("/api/v1/announces", {
|
||||
params: {
|
||||
destination_hash: blocked.destination_hash,
|
||||
include_blocked: true,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (lxmfResponse.data.announces && lxmfResponse.data.announces.length > 0) {
|
||||
const announce = lxmfResponse.data.announces[0];
|
||||
displayName = announce.display_name || "Unknown";
|
||||
isNode = announce.aspect === "nomadnetwork.node";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
return {
|
||||
destination_hash: blocked.destination_hash,
|
||||
display_name: displayName,
|
||||
created_at: blocked.created_at,
|
||||
is_node: isNode,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
this.blockedItems = items;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
ToastUtils.error("Failed to load blocked destinations");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
async onUnblock(item) {
|
||||
if (
|
||||
!(await DialogUtils.confirm(
|
||||
`Are you sure you want to unblock ${item.display_name || item.destination_hash}?`
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/blocked-destinations/${item.destination_hash}`);
|
||||
await this.loadBlockedDestinations();
|
||||
ToastUtils.success("Unblocked successfully");
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
ToastUtils.error("Failed to unblock");
|
||||
}
|
||||
},
|
||||
onSearchInput() {},
|
||||
formatDestinationHash: function (destinationHash) {
|
||||
return Utils.formatDestinationHash(destinationHash);
|
||||
},
|
||||
formatTimeAgo: function (datetimeString) {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
262
meshchatx/src/frontend/components/call/CallOverlay.vue
Normal file
262
meshchatx/src/frontend/components/call/CallOverlay.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="activeCall"
|
||||
class="fixed bottom-4 right-4 z-[100] w-72 bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transition-all duration-300"
|
||||
:class="{ 'ring-2 ring-red-500 ring-opacity-50': isEnded }"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="p-3 flex items-center bg-gray-50 dark:bg-zinc-800/50 border-b border-gray-100 dark:border-zinc-800">
|
||||
<div class="flex-1 flex items-center space-x-2">
|
||||
<div
|
||||
class="size-2 rounded-full"
|
||||
:class="isEnded ? 'bg-red-500' : 'bg-green-500 animate-pulse'"
|
||||
></div>
|
||||
<span class="text-[10px] font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||
{{ isEnded ? "Call Ended" : (activeCall.status === 6 ? "Active Call" : "Call Status") }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isEnded"
|
||||
type="button"
|
||||
class="p-1 hover:bg-gray-200 dark:hover:bg-zinc-700 rounded-lg transition-colors"
|
||||
@click="isMinimized = !isMinimized"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="isMinimized ? 'chevron-up' : 'chevron-down'"
|
||||
class="size-4 text-gray-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="!isMinimized" class="p-4">
|
||||
<!-- icon and name -->
|
||||
<div class="flex flex-col items-center mb-4">
|
||||
<div
|
||||
class="p-4 rounded-full mb-3"
|
||||
:class="isEnded ? 'bg-red-100 dark:bg-red-900/30' : 'bg-blue-100 dark:bg-blue-900/30'"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="account"
|
||||
class="size-8"
|
||||
:class="isEnded ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-center w-full">
|
||||
<div class="font-bold text-gray-900 dark:text-white truncate px-2">
|
||||
{{ activeCall.remote_identity_name || "Unknown" }}
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono">
|
||||
{{
|
||||
activeCall.remote_identity_hash
|
||||
? formatDestinationHash(activeCall.remote_identity_hash)
|
||||
: ""
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="text-center mb-6">
|
||||
<div
|
||||
class="text-sm font-medium"
|
||||
:class="[
|
||||
isEnded ? 'text-red-600 dark:text-red-400 animate-pulse' :
|
||||
(activeCall.status === 6
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-600 dark:text-zinc-400')
|
||||
]"
|
||||
>
|
||||
<span v-if="isEnded">Call Ended</span>
|
||||
<span v-else-if="activeCall.is_incoming && activeCall.status === 4">Incoming Call...</span>
|
||||
<span v-else-if="activeCall.status === 0">Busy</span>
|
||||
<span v-else-if="activeCall.status === 1">Rejected</span>
|
||||
<span v-else-if="activeCall.status === 2">Calling...</span>
|
||||
<span v-else-if="activeCall.status === 3">Available</span>
|
||||
<span v-else-if="activeCall.status === 4">Ringing...</span>
|
||||
<span v-else-if="activeCall.status === 5">Connecting...</span>
|
||||
<span v-else-if="activeCall.status === 6">Connected</span>
|
||||
<span v-else>Status: {{ activeCall.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats (only when connected and not minimized) -->
|
||||
<div
|
||||
v-if="activeCall.status === 6 && !isEnded"
|
||||
class="mb-4 p-2 bg-gray-50 dark:bg-zinc-800/50 rounded-lg text-[10px] text-gray-500 dark:text-zinc-400 grid grid-cols-2 gap-1"
|
||||
>
|
||||
<div class="flex items-center space-x-1">
|
||||
<MaterialDesignIcon icon-name="arrow-up" class="size-3" />
|
||||
<span>{{ formatBytes(activeCall.tx_bytes || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<MaterialDesignIcon icon-name="arrow-down" class="size-3" />
|
||||
<span>{{ formatBytes(activeCall.rx_bytes || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div v-if="!isEnded" class="flex justify-center space-x-3">
|
||||
<!-- Mute Mic -->
|
||||
<button
|
||||
type="button"
|
||||
:title="isMicMuted ? 'Unmute Mic' : 'Mute Mic'"
|
||||
class="p-3 rounded-full transition-all duration-200"
|
||||
:class="
|
||||
isMicMuted
|
||||
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
|
||||
: 'bg-gray-100 dark:bg-zinc-800 text-gray-600 dark:text-zinc-300 hover:bg-gray-200 dark:hover:bg-zinc-700'
|
||||
"
|
||||
@click="toggleMicrophone"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="isMicMuted ? 'microphone-off' : 'microphone'" class="size-6" />
|
||||
</button>
|
||||
|
||||
<!-- Mute Speaker -->
|
||||
<button
|
||||
type="button"
|
||||
:title="isSpeakerMuted ? 'Unmute Speaker' : 'Mute Speaker'"
|
||||
class="p-3 rounded-full transition-all duration-200"
|
||||
:class="
|
||||
isSpeakerMuted
|
||||
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
|
||||
: 'bg-gray-100 dark:bg-zinc-800 text-gray-600 dark:text-zinc-300 hover:bg-gray-200 dark:hover:bg-zinc-700'
|
||||
"
|
||||
@click="toggleSpeaker"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'" class="size-6" />
|
||||
</button>
|
||||
|
||||
<!-- Hangup -->
|
||||
<button
|
||||
type="button"
|
||||
:title="activeCall.is_incoming && activeCall.status === 4 ? 'Decline' : 'Hangup'"
|
||||
class="p-3 rounded-full bg-red-600 text-white hover:bg-red-700 shadow-lg shadow-red-600/30 transition-all duration-200"
|
||||
@click="hangupCall"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" />
|
||||
</button>
|
||||
|
||||
<!-- Answer (if incoming) -->
|
||||
<button
|
||||
v-if="activeCall.is_incoming && activeCall.status === 4"
|
||||
type="button"
|
||||
title="Answer"
|
||||
class="p-3 rounded-full bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-600/30 animate-bounce"
|
||||
@click="answerCall"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="phone" class="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Minimized State -->
|
||||
<div v-show="isMinimized && !isEnded" class="px-4 py-2 flex items-center justify-between bg-white dark:bg-zinc-900">
|
||||
<div class="flex items-center space-x-2 overflow-hidden mr-2">
|
||||
<MaterialDesignIcon icon-name="account" class="size-5 text-blue-500" />
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-zinc-200 truncate">
|
||||
{{ activeCall.remote_identity_name || "Unknown" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded transition-colors"
|
||||
@click="toggleMicrophone"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="isMicMuted ? 'microphone-off' : 'microphone'"
|
||||
class="size-4"
|
||||
:class="isMicMuted ? 'text-red-500' : 'text-gray-400'"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors"
|
||||
@click="hangupCall"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="phone-hangup" class="size-4 text-red-500 rotate-[135deg]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import Utils from "../../js/Utils";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
|
||||
export default {
|
||||
name: "CallOverlay",
|
||||
components: { MaterialDesignIcon },
|
||||
props: {
|
||||
activeCall: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isEnded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isMinimized: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isMicMuted() {
|
||||
return this.activeCall?.is_mic_muted ?? false;
|
||||
},
|
||||
isSpeakerMuted() {
|
||||
return this.activeCall?.is_speaker_muted ?? false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatDestinationHash(hash) {
|
||||
return Utils.formatDestinationHash(hash);
|
||||
},
|
||||
formatBytes(bytes) {
|
||||
return Utils.formatBytes(bytes || 0);
|
||||
},
|
||||
async answerCall() {
|
||||
try {
|
||||
await window.axios.get("/api/v1/telephone/answer");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to answer call");
|
||||
}
|
||||
},
|
||||
async hangupCall() {
|
||||
try {
|
||||
await window.axios.get("/api/v1/telephone/hangup");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to hangup call");
|
||||
}
|
||||
},
|
||||
async toggleMicrophone() {
|
||||
try {
|
||||
const endpoint = this.isMicMuted
|
||||
? "/api/v1/telephone/unmute-transmit"
|
||||
: "/api/v1/telephone/mute-transmit";
|
||||
await window.axios.get(endpoint);
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
this.activeCall.is_mic_muted = !this.isMicMuted;
|
||||
} catch {
|
||||
ToastUtils.error("Failed to toggle microphone");
|
||||
}
|
||||
},
|
||||
async toggleSpeaker() {
|
||||
try {
|
||||
const endpoint = this.isSpeakerMuted
|
||||
? "/api/v1/telephone/unmute-receive"
|
||||
: "/api/v1/telephone/mute-receive";
|
||||
await window.axios.get(endpoint);
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
this.activeCall.is_speaker_muted = !this.isSpeakerMuted;
|
||||
} catch {
|
||||
ToastUtils.error("Failed to toggle speaker");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
458
meshchatx/src/frontend/components/call/CallPage.vue
Normal file
458
meshchatx/src/frontend/components/call/CallPage.vue
Normal file
@@ -0,0 +1,458 @@
|
||||
<template>
|
||||
<div class="flex w-full h-full bg-gray-100 dark:bg-zinc-950" :class="{ dark: config?.theme === 'dark' }">
|
||||
<div class="mx-auto my-auto w-full max-w-xl p-4">
|
||||
<div v-if="activeCall || isCallEnded" class="flex">
|
||||
<div class="mx-auto my-auto min-w-64">
|
||||
<div class="text-center">
|
||||
<div>
|
||||
<!-- icon -->
|
||||
<div class="flex mb-4">
|
||||
<div
|
||||
class="mx-auto bg-gray-300 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-4 rounded-full"
|
||||
:class="{ 'animate-pulse': activeCall && activeCall.status === 4 }"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account" class="size-12" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- name -->
|
||||
<div class="text-xl font-semibold text-gray-500 dark:text-zinc-100">
|
||||
<span v-if="(activeCall || lastCall)?.remote_identity_name != null">{{
|
||||
(activeCall || lastCall).remote_identity_name
|
||||
}}</span>
|
||||
<span v-else>Unknown</span>
|
||||
</div>
|
||||
|
||||
<!-- identity hash -->
|
||||
<div
|
||||
v-if="(activeCall || lastCall)?.remote_identity_hash != null"
|
||||
class="text-gray-500 dark:text-zinc-100 opacity-60 text-sm"
|
||||
>
|
||||
{{
|
||||
(activeCall || lastCall).remote_identity_hash
|
||||
? formatDestinationHash((activeCall || lastCall).remote_identity_hash)
|
||||
: ""
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- call status -->
|
||||
<div class="text-gray-500 dark:text-zinc-100 mb-4 mt-2">
|
||||
<template v-if="isCallEnded">
|
||||
<span class="text-red-500 font-bold animate-pulse">Call Ended</span>
|
||||
</template>
|
||||
<template v-else-if="activeCall">
|
||||
<span v-if="activeCall.is_incoming && activeCall.status === 4" class="animate-bounce inline-block">Incoming Call...</span>
|
||||
<span v-else>
|
||||
<span v-if="activeCall.status === 0">Busy...</span>
|
||||
<span v-else-if="activeCall.status === 1">Rejected...</span>
|
||||
<span v-else-if="activeCall.status === 2">Calling...</span>
|
||||
<span v-else-if="activeCall.status === 3">Available...</span>
|
||||
<span v-else-if="activeCall.status === 4">Ringing...</span>
|
||||
<span v-else-if="activeCall.status === 5">Connecting...</span>
|
||||
<span v-else-if="activeCall.status === 6" class="text-green-500 font-medium">Connected</span>
|
||||
<span v-else>Status: {{ activeCall.status }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- settings during connected call -->
|
||||
<div v-if="activeCall && activeCall.status === 6" class="mb-4">
|
||||
<div class="w-full">
|
||||
<select
|
||||
v-model="selectedAudioProfileId"
|
||||
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 dark:focus:ring-blue-600 dark:focus:border-blue-600"
|
||||
@change="switchAudioProfile(selectedAudioProfileId)"
|
||||
>
|
||||
<option
|
||||
v-for="audioProfile in audioProfiles"
|
||||
:key="audioProfile.id"
|
||||
:value="audioProfile.id"
|
||||
>
|
||||
{{ audioProfile.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- controls during connected call -->
|
||||
<div v-if="activeCall && activeCall.status === 6" class="mx-auto space-x-4 mb-8">
|
||||
<!-- mute/unmute mic -->
|
||||
<button
|
||||
type="button"
|
||||
:title="isMicMuted ? 'Unmute Mic' : 'Mute Mic'"
|
||||
:class="[
|
||||
isMicMuted
|
||||
? 'bg-red-500 hover:bg-red-400'
|
||||
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
|
||||
]"
|
||||
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
|
||||
@click="toggleMicrophone"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="isMicMuted ? 'microphone-off' : 'microphone'"
|
||||
class="size-8"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- mute/unmute speaker -->
|
||||
<button
|
||||
type="button"
|
||||
:title="isSpeakerMuted ? 'Unmute Speaker' : 'Mute Speaker'"
|
||||
:class="[
|
||||
isSpeakerMuted
|
||||
? 'bg-red-500 hover:bg-red-400'
|
||||
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
|
||||
]"
|
||||
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
|
||||
@click="toggleSpeaker"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'"
|
||||
class="size-8"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- toggle stats -->
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
isShowingStats
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
|
||||
]"
|
||||
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
|
||||
@click="isShowingStats = !isShowingStats"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="chart-bar" class="size-8" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div v-if="activeCall" class="mx-auto space-x-4">
|
||||
<!-- answer call -->
|
||||
<button
|
||||
v-if="activeCall.is_incoming && activeCall.status === 4"
|
||||
title="Answer Call"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-2xl bg-green-600 px-6 py-4 text-lg font-bold text-white shadow-xl hover:bg-green-500 transition-all duration-200 animate-bounce"
|
||||
@click="answerCall"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="phone" class="size-6" />
|
||||
<span>Accept</span>
|
||||
</button>
|
||||
|
||||
<!-- hangup/decline call -->
|
||||
<button
|
||||
:title="
|
||||
activeCall.is_incoming && activeCall.status === 4 ? 'Decline Call' : 'Hangup Call'
|
||||
"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-6 py-4 text-lg font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200"
|
||||
@click="hangupCall"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" />
|
||||
<span>{{
|
||||
activeCall.is_incoming && activeCall.status === 4 ? "Decline" : "Hangup"
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- stats -->
|
||||
<div
|
||||
v-if="isShowingStats"
|
||||
class="mt-4 p-4 text-left bg-gray-200 dark:bg-zinc-800 rounded-lg text-sm text-gray-600 dark:text-zinc-300"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
TX: {{ activeCall.tx_packets }} ({{ formatBytes(activeCall.tx_bytes) }})
|
||||
</div>
|
||||
<div>
|
||||
RX: {{ activeCall.rx_packets }} ({{ formatBytes(activeCall.rx_bytes) }})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex">
|
||||
<div class="mx-auto my-auto w-full">
|
||||
<div class="text-center mb-4">
|
||||
<div class="text-xl font-semibold text-gray-500 dark:text-zinc-100">Telephone</div>
|
||||
<div class="text-gray-500 dark:text-zinc-400">Enter an identity hash to call.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
v-model="destinationHash"
|
||||
type="text"
|
||||
placeholder="Identity Hash"
|
||||
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-zinc-800"
|
||||
@keydown.enter="call(destinationHash)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="call(destinationHash)"
|
||||
>
|
||||
Call
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="callHistory.length > 0 && !activeCall" class="mt-8">
|
||||
<div
|
||||
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="px-4 py-3 border-b border-gray-200 dark:border-zinc-800 flex justify-between items-center"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">
|
||||
Call History
|
||||
</h3>
|
||||
<MaterialDesignIcon icon-name="history" class="size-4 text-gray-400" />
|
||||
</div>
|
||||
<ul class="divide-y divide-gray-100 dark:divide-zinc-800">
|
||||
<li
|
||||
v-for="entry in callHistory"
|
||||
:key="entry.id"
|
||||
class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div :class="entry.is_incoming ? 'text-blue-500' : 'text-green-500'">
|
||||
<MaterialDesignIcon
|
||||
:icon-name="entry.is_incoming ? 'phone-incoming' : 'phone-outgoing'"
|
||||
class="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||
{{ entry.remote_identity_name || "Unknown" }}
|
||||
</p>
|
||||
<span class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono ml-2">
|
||||
{{
|
||||
entry.timestamp
|
||||
? formatDateTime(
|
||||
entry.timestamp * 1000
|
||||
)
|
||||
: ""
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-0.5">
|
||||
<div
|
||||
class="flex items-center text-xs text-gray-500 dark:text-zinc-400 space-x-2"
|
||||
>
|
||||
<span>{{ entry.status }}</span>
|
||||
<span v-if="entry.duration_seconds > 0"
|
||||
>• {{ formatDuration(entry.duration_seconds) }}</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[10px] text-blue-500 hover:text-blue-600 font-bold uppercase tracking-tighter"
|
||||
@click="
|
||||
destinationHash = entry.remote_identity_hash;
|
||||
call(destinationHash);
|
||||
"
|
||||
>
|
||||
Call Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Utils from "../../js/Utils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
|
||||
export default {
|
||||
name: "CallPage",
|
||||
components: { MaterialDesignIcon },
|
||||
data() {
|
||||
return {
|
||||
config: null,
|
||||
activeCall: null,
|
||||
audioProfiles: [],
|
||||
selectedAudioProfileId: null,
|
||||
destinationHash: "",
|
||||
isShowingStats: false,
|
||||
callHistory: [],
|
||||
isCallEnded: false,
|
||||
lastCall: null,
|
||||
endedTimeout: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isMicMuted() {
|
||||
return this.activeCall?.is_mic_muted ?? false;
|
||||
},
|
||||
isSpeakerMuted() {
|
||||
return this.activeCall?.is_speaker_muted ?? false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
this.getAudioProfiles();
|
||||
this.getStatus();
|
||||
this.getHistory();
|
||||
|
||||
// poll for status
|
||||
this.statusInterval = setInterval(() => {
|
||||
this.getStatus();
|
||||
}, 1000);
|
||||
|
||||
// poll for history less frequently
|
||||
this.historyInterval = setInterval(() => {
|
||||
this.getHistory();
|
||||
}, 10000);
|
||||
|
||||
// autofill destination hash from query string
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const destinationHash = urlParams.get("destination_hash");
|
||||
if (destinationHash) {
|
||||
this.destinationHash = destinationHash;
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.statusInterval) clearInterval(this.statusInterval);
|
||||
if (this.historyInterval) clearInterval(this.historyInterval);
|
||||
if (this.endedTimeout) clearTimeout(this.endedTimeout);
|
||||
},
|
||||
methods: {
|
||||
formatDestinationHash(hash) {
|
||||
return Utils.formatDestinationHash(hash);
|
||||
},
|
||||
formatBytes(bytes) {
|
||||
return Utils.formatBytes(bytes || 0);
|
||||
},
|
||||
formatDateTime(timestamp) {
|
||||
return Utils.convertUnixMillisToLocalDateTimeString(timestamp);
|
||||
},
|
||||
formatDuration(seconds) {
|
||||
return Utils.formatMinutesSeconds(seconds);
|
||||
},
|
||||
async getConfig() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/config");
|
||||
this.config = response.data.config;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async getAudioProfiles() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telephone/audio-profiles");
|
||||
this.audioProfiles = response.data.audio_profiles;
|
||||
this.selectedAudioProfileId = response.data.default_audio_profile_id;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async getStatus() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telephone/status");
|
||||
const oldCall = this.activeCall;
|
||||
this.activeCall = response.data.active_call;
|
||||
|
||||
// If call just ended, refresh history and show ended state
|
||||
if (oldCall != null && this.activeCall == null) {
|
||||
this.getHistory();
|
||||
this.lastCall = oldCall;
|
||||
this.isCallEnded = true;
|
||||
|
||||
if (this.endedTimeout) clearTimeout(this.endedTimeout);
|
||||
this.endedTimeout = setTimeout(() => {
|
||||
this.isCallEnded = false;
|
||||
this.lastCall = null;
|
||||
}, 5000);
|
||||
} else if (this.activeCall != null) {
|
||||
// if a new call starts, clear ended state
|
||||
this.isCallEnded = false;
|
||||
this.lastCall = null;
|
||||
if (this.endedTimeout) clearTimeout(this.endedTimeout);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async getHistory() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telephone/history?limit=10");
|
||||
this.callHistory = response.data.call_history;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async call(identityHash) {
|
||||
if (!identityHash) {
|
||||
ToastUtils.error("Enter an identity hash to call");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.axios.get(`/api/v1/telephone/call/${identityHash}`);
|
||||
} catch (e) {
|
||||
ToastUtils.error(e.response?.data?.message || "Failed to initiate call");
|
||||
}
|
||||
},
|
||||
async answerCall() {
|
||||
try {
|
||||
await window.axios.get("/api/v1/telephone/answer");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to answer call");
|
||||
}
|
||||
},
|
||||
async hangupCall() {
|
||||
try {
|
||||
await window.axios.get("/api/v1/telephone/hangup");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to hangup call");
|
||||
}
|
||||
},
|
||||
async switchAudioProfile(audioProfileId) {
|
||||
try {
|
||||
await window.axios.get(`/api/v1/telephone/switch-audio-profile/${audioProfileId}`);
|
||||
} catch {
|
||||
ToastUtils.error("Failed to switch audio profile");
|
||||
}
|
||||
},
|
||||
async toggleMicrophone() {
|
||||
try {
|
||||
const endpoint = this.isMicMuted
|
||||
? "/api/v1/telephone/unmute-transmit"
|
||||
: "/api/v1/telephone/mute-transmit";
|
||||
await window.axios.get(endpoint);
|
||||
if (this.activeCall) {
|
||||
this.activeCall.is_mic_muted = !this.isMicMuted;
|
||||
}
|
||||
} catch {
|
||||
ToastUtils.error("Failed to toggle microphone");
|
||||
}
|
||||
},
|
||||
async toggleSpeaker() {
|
||||
try {
|
||||
const endpoint = this.isSpeakerMuted
|
||||
? "/api/v1/telephone/unmute-receive"
|
||||
: "/api/v1/telephone/mute-receive";
|
||||
await window.axios.get(endpoint);
|
||||
if (this.activeCall) {
|
||||
this.activeCall.is_speaker_muted = !this.isSpeakerMuted;
|
||||
}
|
||||
} catch {
|
||||
ToastUtils.error("Failed to toggle speaker");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
10
meshchatx/src/frontend/components/forms/FormLabel.vue
Normal file
10
meshchatx/src/frontend/components/forms/FormLabel.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<label class="block text-sm font-medium text-gray-900 dark:text-zinc-100">
|
||||
<slot />
|
||||
</label>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: "FormLabel",
|
||||
};
|
||||
</script>
|
||||
10
meshchatx/src/frontend/components/forms/FormSubLabel.vue
Normal file
10
meshchatx/src/frontend/components/forms/FormSubLabel.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div class="text-xs text-gray-600 dark:text-zinc-300">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: "FormSubLabel",
|
||||
};
|
||||
</script>
|
||||
36
meshchatx/src/frontend/components/forms/Toggle.vue
Normal file
36
meshchatx/src/frontend/components/forms/Toggle.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<label :for="id" class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
:id="id"
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
class="sr-only peer"
|
||||
@change="$emit('update:modelValue', $event.target.checked)"
|
||||
/>
|
||||
<div
|
||||
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-zinc-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"
|
||||
></div>
|
||||
<span v-if="label" class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Toggle",
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
};
|
||||
</script>
|
||||
202
meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
Normal file
202
meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
|
||||
>
|
||||
<div class="overflow-y-auto space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
|
||||
<div class="glass-card space-y-3">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("tools.utilities") }}
|
||||
</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ $t("forwarder.title") }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $t("forwarder.description") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add New Rule -->
|
||||
<div class="glass-card space-y-4">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ $t("forwarder.add_rule") }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{
|
||||
$t("forwarder.forward_to_hash")
|
||||
}}</label>
|
||||
<input
|
||||
v-model="newRule.forward_to_hash"
|
||||
type="text"
|
||||
:placeholder="$t('forwarder.destination_placeholder')"
|
||||
class="w-full px-4 py-2 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{
|
||||
$t("forwarder.source_filter")
|
||||
}}</label>
|
||||
<input
|
||||
v-model="newRule.source_filter_hash"
|
||||
type="text"
|
||||
:placeholder="$t('forwarder.source_filter_placeholder')"
|
||||
class="w-full px-4 py-2 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition-colors flex items-center gap-2"
|
||||
@click="addRule"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="plus" class="w-5 h-5" />
|
||||
{{ $t("forwarder.add_button") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rules List -->
|
||||
<div class="space-y-4">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("forwarder.active_rules") }}
|
||||
</div>
|
||||
<div v-if="rules.length === 0" class="glass-card text-center py-12 text-gray-500 dark:text-zinc-400">
|
||||
{{ $t("forwarder.no_rules") }}
|
||||
</div>
|
||||
<div v-for="rule in rules" :key="rule.id" class="glass-card flex items-center justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider"
|
||||
:class="
|
||||
rule.is_active
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-400'
|
||||
"
|
||||
>
|
||||
{{ rule.is_active ? $t("forwarder.active") : $t("forwarder.disabled") }}
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-zinc-400">ID: {{ rule.id }}</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="arrow-right" class="w-4 h-4 text-blue-500 shrink-0" />
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{{ $t("forwarder.forwarding_to", { hash: rule.forward_to_hash }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="rule.source_filter_hash" class="flex items-center gap-2">
|
||||
<MaterialDesignIcon
|
||||
icon-name="filter-variant"
|
||||
class="w-4 h-4 text-purple-500 shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 dark:text-zinc-300 truncate">
|
||||
{{ $t("forwarder.source_filter_display", { hash: rule.source_filter_hash }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
:title="rule.is_active ? $t('forwarder.disabled') : $t('forwarder.active')"
|
||||
@click="toggleRule(rule.id)"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="rule.is_active ? 'toggle-switch' : 'toggle-switch-off'"
|
||||
class="w-6 h-6"
|
||||
:class="rule.is_active ? 'text-blue-500' : 'text-gray-400'"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 rounded-lg transition-colors"
|
||||
:title="$t('common.delete')"
|
||||
@click="deleteRule(rule.id)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
|
||||
export default {
|
||||
name: "ForwarderPage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
rules: [],
|
||||
newRule: {
|
||||
forward_to_hash: "",
|
||||
source_filter_hash: "",
|
||||
is_active: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
this.fetchRules();
|
||||
},
|
||||
beforeUnmount() {
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
},
|
||||
methods: {
|
||||
fetchRules() {
|
||||
WebSocketConnection.send(
|
||||
JSON.stringify({
|
||||
type: "lxmf.forwarding.rules.get",
|
||||
})
|
||||
);
|
||||
},
|
||||
onWebsocketMessage(message) {
|
||||
try {
|
||||
const data = JSON.parse(message.data);
|
||||
if (data.type === "lxmf.forwarding.rules") {
|
||||
this.rules = data.rules;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse websocket message", e);
|
||||
}
|
||||
},
|
||||
addRule() {
|
||||
if (!this.newRule.forward_to_hash) return;
|
||||
WebSocketConnection.send(
|
||||
JSON.stringify({
|
||||
type: "lxmf.forwarding.rule.add",
|
||||
rule: { ...this.newRule },
|
||||
})
|
||||
);
|
||||
this.newRule.forward_to_hash = "";
|
||||
this.newRule.source_filter_hash = "";
|
||||
},
|
||||
deleteRule(id) {
|
||||
if (confirm(this.$t("forwarder.delete_confirm"))) {
|
||||
WebSocketConnection.send(
|
||||
JSON.stringify({
|
||||
type: "lxmf.forwarding.rule.delete",
|
||||
id: id,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
toggleRule(id) {
|
||||
WebSocketConnection.send(
|
||||
JSON.stringify({
|
||||
type: "lxmf.forwarding.rule.toggle",
|
||||
id: id,
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.glass-card {
|
||||
@apply bg-white/80 dark:bg-zinc-900/80 backdrop-blur-md border border-gray-200 dark:border-zinc-800 p-6 rounded-3xl shadow-sm;
|
||||
}
|
||||
</style>
|
||||
1597
meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
Normal file
1597
meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="bg-white rounded shadow divide-y divide-gray-300 dark:divide-zinc-700 dark:bg-zinc-900 overflow-hidden">
|
||||
<div
|
||||
class="flex p-2 justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800"
|
||||
@click="isExpanded = !isExpanded"
|
||||
>
|
||||
<div class="my-auto mr-auto">
|
||||
<div class="font-bold dark:text-white">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto ml-2">
|
||||
<div
|
||||
class="w-5 h-5 text-gray-600 dark:text-gray-300 transform transition-transform duration-200"
|
||||
:class="{ 'rotate-90': isExpanded }"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="size-5">
|
||||
<rect width="256" height="256" fill="none" />
|
||||
<path
|
||||
d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isExpanded" class="divide-y divide-gray-200 dark:text-white">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: "ExpandingSection",
|
||||
data() {
|
||||
return {
|
||||
isExpanded: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isShowing"
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<div class="flex w-full h-full p-4 overflow-y-auto">
|
||||
<div
|
||||
v-click-outside="dismiss"
|
||||
class="my-auto mx-auto w-full bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl"
|
||||
>
|
||||
<!-- title -->
|
||||
<div class="p-4 border-b dark:border-zinc-700">
|
||||
<h3 class="text-lg font-semibold dark:text-white">Import Interfaces</h3>
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
<!-- file input -->
|
||||
<div class="p-2">
|
||||
<div>
|
||||
<input
|
||||
ref="import-interfaces-file-input"
|
||||
type="file"
|
||||
accept="*"
|
||||
class="w-full text-sm text-gray-500 dark:text-zinc-400"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!selectedFile" class="mt-2 text-sm text-gray-700 dark:text-zinc-200">
|
||||
<ul class="list-disc list-inside">
|
||||
<li>You can import interfaces from a ~/.reticulum/config file.</li>
|
||||
<li>You can import interfaces from an exported interfaces file.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- select interfaces -->
|
||||
<div v-if="importableInterfaces.length > 0" class="divide-y dark:divide-zinc-700">
|
||||
<div class="flex p-2">
|
||||
<div class="my-auto mr-auto text-sm font-medium text-gray-700 dark:text-zinc-200">
|
||||
Select Interfaces to Import
|
||||
</div>
|
||||
<div class="my-auto space-x-2">
|
||||
<button class="text-sm text-blue-500 hover:underline" @click="selectAllInterfaces">
|
||||
Select All
|
||||
</button>
|
||||
<button class="text-sm text-blue-500 hover:underline" @click="deselectAllInterfaces">
|
||||
Deselect All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-200 p-2 space-y-2 max-h-80 overflow-y-auto dark:bg-zinc-800">
|
||||
<div
|
||||
v-for="iface in importableInterfaces"
|
||||
:key="iface.name"
|
||||
class="bg-white cursor-pointer flex items-center p-2 border rounded shadow dark:bg-zinc-900 dark:border-zinc-700"
|
||||
>
|
||||
<div class="mr-auto text-sm flex-1" @click="toggleSelectedInterface(iface.name)">
|
||||
<div class="font-semibold text-gray-700 dark:text-zinc-100">{{ iface.name }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-zinc-100">
|
||||
<!-- auto interface -->
|
||||
<div v-if="iface.type === 'AutoInterface'">
|
||||
<div>{{ iface.type }}</div>
|
||||
<div>Ethernet and WiFi</div>
|
||||
</div>
|
||||
|
||||
<!-- tcp client interface -->
|
||||
<div v-else-if="iface.type === 'TCPClientInterface'">
|
||||
<div>{{ iface.type }}</div>
|
||||
<div>{{ iface.target_host }}:{{ iface.target_port }}</div>
|
||||
</div>
|
||||
|
||||
<!-- tcp server interface -->
|
||||
<div v-else-if="iface.type === 'TCPServerInterface'">
|
||||
<div>{{ iface.type }}</div>
|
||||
<div>{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||
</div>
|
||||
|
||||
<!-- udp interface -->
|
||||
<div v-else-if="iface.type === 'UDPInterface'">
|
||||
<div>{{ iface.type }}</div>
|
||||
<div>Listen: {{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||
<div>Forward: {{ iface.forward_ip }}:{{ iface.forward_port }}</div>
|
||||
</div>
|
||||
|
||||
<!-- rnode interface details -->
|
||||
<div v-else-if="iface.type === 'RNodeInterface'">
|
||||
<div>{{ iface.type }}</div>
|
||||
<div>Port: {{ iface.port }}</div>
|
||||
<div>Frequency: {{ formatFrequency(iface.frequency) }}</div>
|
||||
<div>Bandwidth: {{ formatFrequency(iface.bandwidth) }}</div>
|
||||
<div>Spreading Factor: {{ iface.spreadingfactor }}</div>
|
||||
<div>Coding Rate: {{ iface.codingrate }}</div>
|
||||
<div>Transmit Power: {{ iface.txpower }}dBm</div>
|
||||
</div>
|
||||
|
||||
<!-- other interface types -->
|
||||
<div v-else>{{ iface.type }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div @click.stop>
|
||||
<Toggle
|
||||
:id="`import-interface-${iface.name}`"
|
||||
:model-value="selectedInterfaces.includes(iface.name)"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
if (value && !selectedInterfaces.includes(iface.name))
|
||||
selectInterface(iface.name);
|
||||
else if (!value && selectedInterfaces.includes(iface.name))
|
||||
deselectInterface(iface.name);
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="p-4 border-t dark:border-zinc-700 flex justify-end space-x-2">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-zinc-800 dark:text-zinc-200 dark:border-zinc-600 dark:hover:bg-zinc-700"
|
||||
@click="dismiss"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600"
|
||||
@click="importSelectedInterfaces"
|
||||
>
|
||||
Import Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import Utils from "../../js/Utils";
|
||||
import Toggle from "../forms/Toggle.vue";
|
||||
|
||||
export default {
|
||||
name: "ImportInterfacesModal",
|
||||
components: {
|
||||
Toggle,
|
||||
},
|
||||
emits: ["dismissed"],
|
||||
data() {
|
||||
return {
|
||||
isShowing: false,
|
||||
selectedFile: null,
|
||||
importableInterfaces: [],
|
||||
selectedInterfaces: [],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
show() {
|
||||
this.isShowing = true;
|
||||
this.selectedFile = null;
|
||||
this.importableInterfaces = [];
|
||||
this.selectedInterfaces = [];
|
||||
},
|
||||
dismiss(result = false) {
|
||||
this.isShowing = false;
|
||||
const imported = result === true;
|
||||
this.$emit("dismissed", imported);
|
||||
},
|
||||
clearSelectedFile() {
|
||||
this.selectedFile = null;
|
||||
this.$refs["import-interfaces-file-input"].value = null;
|
||||
},
|
||||
async onFileSelected(event) {
|
||||
// get selected file
|
||||
const file = event.target.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// update ui
|
||||
this.selectedFile = file;
|
||||
this.importableInterfaces = [];
|
||||
this.selectedInterfaces = [];
|
||||
|
||||
try {
|
||||
// fetch preview of interfaces to import
|
||||
const response = await window.axios.post("/api/v1/reticulum/interfaces/import-preview", {
|
||||
config: await file.text(),
|
||||
});
|
||||
|
||||
// ensure there are some interfaces available to import
|
||||
if (!response.data.interfaces || response.data.interfaces.length === 0) {
|
||||
this.clearSelectedFile();
|
||||
DialogUtils.alert("No interfaces were found in the selected configuration file");
|
||||
return;
|
||||
}
|
||||
|
||||
// update ui
|
||||
this.importableInterfaces = response.data.interfaces;
|
||||
|
||||
// auto select all interfaces
|
||||
this.selectAllInterfaces();
|
||||
} catch (e) {
|
||||
this.clearSelectedFile();
|
||||
DialogUtils.alert("Failed to parse configuration file");
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
isInterfaceSelected(name) {
|
||||
return this.selectedInterfaces.includes(name);
|
||||
},
|
||||
selectInterface(name) {
|
||||
if (!this.isInterfaceSelected(name)) {
|
||||
this.selectedInterfaces.push(name);
|
||||
}
|
||||
},
|
||||
deselectInterface(name) {
|
||||
this.selectedInterfaces = this.selectedInterfaces.filter((selectedInterfaceName) => {
|
||||
return selectedInterfaceName !== name;
|
||||
});
|
||||
},
|
||||
toggleSelectedInterface(name) {
|
||||
if (this.isInterfaceSelected(name)) {
|
||||
this.deselectInterface(name);
|
||||
} else {
|
||||
this.selectInterface(name);
|
||||
}
|
||||
},
|
||||
selectAllInterfaces() {
|
||||
this.selectedInterfaces = this.importableInterfaces.map((i) => i.name);
|
||||
},
|
||||
deselectAllInterfaces() {
|
||||
this.selectedInterfaces = [];
|
||||
},
|
||||
async importSelectedInterfaces() {
|
||||
// ensure user selected a file to import from
|
||||
if (!this.selectedFile) {
|
||||
DialogUtils.alert("Please select a configuration file");
|
||||
return;
|
||||
}
|
||||
|
||||
// ensure user selected some interfaces
|
||||
if (this.selectedInterfaces.length === 0) {
|
||||
DialogUtils.alert("Please select at least one interface to import");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// import interfaces
|
||||
await window.axios.post("/api/v1/reticulum/interfaces/import", {
|
||||
config: await this.selectedFile.text(),
|
||||
selected_interface_names: this.selectedInterfaces,
|
||||
});
|
||||
|
||||
// dismiss modal
|
||||
this.dismiss(true);
|
||||
|
||||
// tell user interfaces were imported
|
||||
DialogUtils.alert(
|
||||
"Interfaces imported successfully. MeshChat must be restarted for these changes to take effect."
|
||||
);
|
||||
} catch (e) {
|
||||
const message = e.response?.data?.message || "Failed to import interfaces";
|
||||
DialogUtils.alert(message);
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
formatFrequency(hz) {
|
||||
return Utils.formatFrequency(hz);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
266
meshchatx/src/frontend/components/interfaces/Interface.vue
Normal file
266
meshchatx/src/frontend/components/interfaces/Interface.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="interface-card">
|
||||
<div class="flex gap-4 items-start">
|
||||
<div class="interface-card__icon">
|
||||
<MaterialDesignIcon :icon-name="iconName" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ iface._name }}</div>
|
||||
<span class="type-chip">{{ iface.type }}</span>
|
||||
<span :class="statusChipClass">{{
|
||||
isInterfaceEnabled(iface) ? $t("app.enabled") : $t("app.disabled")
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ description }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span v-if="iface._stats?.bitrate" class="stat-chip"
|
||||
>{{ $t("interface.bitrate") }} {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</span
|
||||
>
|
||||
<span class="stat-chip">{{ $t("interface.tx") }} {{ formatBytes(iface._stats?.txb ?? 0) }}</span>
|
||||
<span class="stat-chip">{{ $t("interface.rx") }} {{ formatBytes(iface._stats?.rxb ?? 0) }}</span>
|
||||
<span v-if="iface.type === 'RNodeInterface' && iface._stats?.noise_floor" class="stat-chip"
|
||||
>{{ $t("interface.noise") }} {{ iface._stats?.noise_floor }} dBm</span
|
||||
>
|
||||
<span v-if="iface._stats?.clients != null" class="stat-chip"
|
||||
>{{ $t("interface.clients") }} {{ iface._stats?.clients }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="iface._stats?.ifac_signature" class="ifac-line">
|
||||
<span class="text-emerald-500 font-semibold">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span>
|
||||
<span v-if="iface._stats?.ifac_netname">• {{ iface._stats.ifac_netname }}</span>
|
||||
<span>•</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-blue-500 hover:underline"
|
||||
@click="onIFACSignatureClick(iface._stats.ifac_signature)"
|
||||
>
|
||||
{{ iface._stats.ifac_signature.slice(0, 8) }}…{{ iface._stats.ifac_signature.slice(-8) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 items-end relative">
|
||||
<button
|
||||
v-if="isInterfaceEnabled(iface)"
|
||||
type="button"
|
||||
class="secondary-chip text-xs"
|
||||
@click="disableInterface"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="power" class="w-4 h-4" />
|
||||
{{ $t("interface.disable") }}
|
||||
</button>
|
||||
<button v-else type="button" class="primary-chip text-xs" @click="enableInterface">
|
||||
<MaterialDesignIcon icon-name="power" class="w-4 h-4" />
|
||||
{{ $t("interface.enable") }}
|
||||
</button>
|
||||
<div class="relative z-50">
|
||||
<DropDownMenu>
|
||||
<template #button>
|
||||
<IconButton>
|
||||
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5" />
|
||||
</IconButton>
|
||||
</template>
|
||||
<template #items>
|
||||
<div class="max-h-60 overflow-auto py-1 space-y-1">
|
||||
<DropDownMenuItem @click="editInterface">
|
||||
<MaterialDesignIcon icon-name="pencil" class="w-5 h-5" />
|
||||
<span>{{ $t("interface.edit_interface") }}</span>
|
||||
</DropDownMenuItem>
|
||||
<DropDownMenuItem @click="exportInterface">
|
||||
<MaterialDesignIcon icon-name="export" class="w-5 h-5" />
|
||||
<span>{{ $t("interface.export_interface") }}</span>
|
||||
</DropDownMenuItem>
|
||||
<DropDownMenuItem @click="deleteInterface">
|
||||
<MaterialDesignIcon icon-name="trash-can" class="w-5 h-5 text-red-500" />
|
||||
<span class="text-red-500">{{ $t("interface.delete_interface") }}</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
</template>
|
||||
</DropDownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="['UDPInterface', 'RNodeInterface'].includes(iface.type)"
|
||||
class="mt-4 grid gap-2 text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<div v-if="iface.type === 'UDPInterface'" class="detail-grid">
|
||||
<div>
|
||||
<div class="detail-label">{{ $t("interface.listen") }}</div>
|
||||
<div class="detail-value">{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">{{ $t("interface.forward") }}</div>
|
||||
<div class="detail-value">{{ iface.forward_ip }}:{{ iface.forward_port }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="iface.type === 'RNodeInterface'" class="detail-grid">
|
||||
<div>
|
||||
<div class="detail-label">{{ $t("interface.port") }}</div>
|
||||
<div class="detail-value">{{ iface.port }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">{{ $t("interface.frequency") }}</div>
|
||||
<div class="detail-value">{{ formatFrequency(iface.frequency) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">{{ $t("interface.bandwidth") }}</div>
|
||||
<div class="detail-value">{{ formatFrequency(iface.bandwidth) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">{{ $t("interface.spreading_factor") }}</div>
|
||||
<div class="detail-value">{{ iface.spreadingfactor }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">{{ $t("interface.coding_rate") }}</div>
|
||||
<div class="detail-value">{{ iface.codingrate }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">{{ $t("interface.txpower") }}</div>
|
||||
<div class="detail-value">{{ iface.txpower }} dBm</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import Utils from "../../js/Utils";
|
||||
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||
import IconButton from "../IconButton.vue";
|
||||
import DropDownMenu from "../DropDownMenu.vue";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "Interface",
|
||||
components: {
|
||||
DropDownMenu,
|
||||
IconButton,
|
||||
DropDownMenuItem,
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
iface: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["enable", "disable", "edit", "export", "delete"],
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
computed: {
|
||||
iconName() {
|
||||
switch (this.iface.type) {
|
||||
case "AutoInterface":
|
||||
return "home-automation";
|
||||
case "RNodeInterface":
|
||||
return "radio-tower";
|
||||
case "RNodeMultiInterface":
|
||||
return "access-point-network";
|
||||
case "TCPClientInterface":
|
||||
return "lan-connect";
|
||||
case "TCPServerInterface":
|
||||
return "lan";
|
||||
case "UDPInterface":
|
||||
return "wan";
|
||||
case "SerialInterface":
|
||||
return "usb-port";
|
||||
case "KISSInterface":
|
||||
case "AX25KISSInterface":
|
||||
return "antenna";
|
||||
case "I2PInterface":
|
||||
return "eye";
|
||||
case "PipeInterface":
|
||||
return "pipe";
|
||||
default:
|
||||
return "server-network";
|
||||
}
|
||||
},
|
||||
description() {
|
||||
if (this.iface.type === "TCPClientInterface") {
|
||||
return `${this.iface.target_host}:${this.iface.target_port}`;
|
||||
}
|
||||
if (this.iface.type === "TCPServerInterface" || this.iface.type === "UDPInterface") {
|
||||
return `${this.iface.listen_ip}:${this.iface.listen_port}`;
|
||||
}
|
||||
if (this.iface.type === "SerialInterface") {
|
||||
return `${this.iface.port} @ ${this.iface.speed || "9600"}bps`;
|
||||
}
|
||||
if (this.iface.type === "AutoInterface") {
|
||||
return "Auto-detect Ethernet and Wi-Fi peers";
|
||||
}
|
||||
return this.iface.description || "Custom interface";
|
||||
},
|
||||
statusChipClass() {
|
||||
return this.isInterfaceEnabled(this.iface)
|
||||
? "inline-flex items-center rounded-full bg-green-100 text-green-700 px-2 py-0.5 text-xs font-semibold"
|
||||
: "inline-flex items-center rounded-full bg-red-100 text-red-700 px-2 py-0.5 text-xs font-semibold";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onIFACSignatureClick: function (ifacSignature) {
|
||||
DialogUtils.alert(ifacSignature);
|
||||
},
|
||||
isInterfaceEnabled: function (iface) {
|
||||
return Utils.isInterfaceEnabled(iface);
|
||||
},
|
||||
enableInterface() {
|
||||
this.$emit("enable");
|
||||
},
|
||||
disableInterface() {
|
||||
this.$emit("disable");
|
||||
},
|
||||
editInterface() {
|
||||
this.$emit("edit");
|
||||
},
|
||||
exportInterface() {
|
||||
this.$emit("export");
|
||||
},
|
||||
deleteInterface() {
|
||||
this.$emit("delete");
|
||||
},
|
||||
formatBitsPerSecond: function (bits) {
|
||||
return Utils.formatBitsPerSecond(bits);
|
||||
},
|
||||
formatBytes: function (bytes) {
|
||||
return Utils.formatBytes(bytes);
|
||||
},
|
||||
formatFrequency(hz) {
|
||||
return Utils.formatFrequency(hz);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.interface-card {
|
||||
@apply bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg p-4 space-y-3;
|
||||
overflow: visible;
|
||||
}
|
||||
.interface-card__icon {
|
||||
@apply w-12 h-12 rounded-2xl bg-blue-50 text-blue-600 dark:bg-blue-900/40 dark:text-blue-200 flex items-center justify-center;
|
||||
}
|
||||
.type-chip {
|
||||
@apply inline-flex items-center rounded-full bg-gray-100 dark:bg-zinc-800 px-2 py-0.5 text-xs font-semibold text-gray-600 dark:text-gray-200;
|
||||
}
|
||||
.stat-chip {
|
||||
@apply inline-flex items-center rounded-full border border-gray-200 dark:border-zinc-700 px-2 py-0.5;
|
||||
}
|
||||
.ifac-line {
|
||||
@apply text-xs flex flex-wrap items-center gap-1;
|
||||
}
|
||||
.detail-grid {
|
||||
@apply grid gap-3 sm:grid-cols-2;
|
||||
}
|
||||
.detail-label {
|
||||
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
.detail-value {
|
||||
@apply text-sm font-medium text-gray-900 dark:text-white;
|
||||
}
|
||||
</style>
|
||||
362
meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
Normal file
362
meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
|
||||
>
|
||||
<div class="overflow-y-auto p-3 md:p-6 space-y-4 max-w-6xl mx-auto w-full">
|
||||
<div
|
||||
v-if="showRestartReminder"
|
||||
class="bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-3xl shadow-xl p-4 flex flex-wrap gap-3 items-center"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<MaterialDesignIcon icon-name="alert" class="w-6 h-6" />
|
||||
<div>
|
||||
<div class="text-lg font-semibold">{{ $t("interfaces.restart_required") }}</div>
|
||||
<div class="text-sm">{{ $t("interfaces.restart_description") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="isElectron"
|
||||
type="button"
|
||||
class="ml-auto inline-flex items-center gap-2 rounded-full border border-white/40 px-4 py-1.5 text-sm font-semibold text-white hover:bg-white/10 transition"
|
||||
@click="relaunch"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="restart" class="w-4 h-4" />
|
||||
{{ $t("interfaces.restart_now") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="glass-card space-y-4">
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("interfaces.manage") }}
|
||||
</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("interfaces.title") }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $t("interfaces.description") }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
|
||||
<MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
|
||||
{{ $t("interfaces.add_interface") }}
|
||||
</RouterLink>
|
||||
<button type="button" class="secondary-chip text-sm" @click="showImportInterfacesModal">
|
||||
<MaterialDesignIcon icon-name="import" class="w-4 h-4" />
|
||||
{{ $t("interfaces.import") }}
|
||||
</button>
|
||||
<button type="button" class="secondary-chip text-sm" @click="exportInterfaces">
|
||||
<MaterialDesignIcon icon-name="export" class="w-4 h-4" />
|
||||
{{ $t("interfaces.export_all") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
:placeholder="$t('interfaces.search_placeholder')"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
:class="filterChipClass(statusFilter === 'all')"
|
||||
@click="setStatusFilter('all')"
|
||||
>
|
||||
{{ $t("interfaces.all") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="filterChipClass(statusFilter === 'enabled')"
|
||||
@click="setStatusFilter('enabled')"
|
||||
>
|
||||
{{ $t("app.enabled") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="filterChipClass(statusFilter === 'disabled')"
|
||||
@click="setStatusFilter('disabled')"
|
||||
>
|
||||
{{ $t("app.disabled") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full sm:w-60">
|
||||
<select v-model="typeFilter" class="input-field">
|
||||
<option value="all">{{ $t("interfaces.all_types") }}</option>
|
||||
<option v-for="type in sortedInterfaceTypes" :key="type" :value="type">{{ type }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredInterfaces.length === 0"
|
||||
class="glass-card text-center py-10 text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="lan-disconnect" class="w-10 h-10 mx-auto mb-3" />
|
||||
<div class="text-lg font-semibold">{{ $t("interfaces.no_interfaces_found") }}</div>
|
||||
<div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 xl:grid-cols-2">
|
||||
<Interface
|
||||
v-for="iface of filteredInterfaces"
|
||||
:key="iface._name"
|
||||
:iface="iface"
|
||||
@enable="enableInterface(iface._name)"
|
||||
@disable="disableInterface(iface._name)"
|
||||
@edit="editInterface(iface._name)"
|
||||
@export="exportInterface(iface._name)"
|
||||
@delete="deleteInterface(iface._name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImportInterfacesModal ref="import-interfaces-modal" @dismissed="onImportInterfacesModalDismissed" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import ElectronUtils from "../../js/ElectronUtils";
|
||||
import Interface from "./Interface.vue";
|
||||
import Utils from "../../js/Utils";
|
||||
import ImportInterfacesModal from "./ImportInterfacesModal.vue";
|
||||
import DownloadUtils from "../../js/DownloadUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "InterfacesPage",
|
||||
components: {
|
||||
ImportInterfacesModal,
|
||||
Interface,
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
interfaces: {},
|
||||
interfaceStats: {},
|
||||
reloadInterval: null,
|
||||
searchTerm: "",
|
||||
statusFilter: "all",
|
||||
typeFilter: "all",
|
||||
hasPendingInterfaceChanges: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isElectron() {
|
||||
return ElectronUtils.isElectron();
|
||||
},
|
||||
showRestartReminder() {
|
||||
return this.hasPendingInterfaceChanges;
|
||||
},
|
||||
interfacesWithStats() {
|
||||
const results = [];
|
||||
for (const [interfaceName, iface] of Object.entries(this.interfaces)) {
|
||||
iface._name = interfaceName;
|
||||
iface._stats = this.interfaceStats[interfaceName];
|
||||
results.push(iface);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
enabledInterfaces() {
|
||||
return this.interfacesWithStats.filter((iface) => this.isInterfaceEnabled(iface));
|
||||
},
|
||||
disabledInterfaces() {
|
||||
return this.interfacesWithStats.filter((iface) => !this.isInterfaceEnabled(iface));
|
||||
},
|
||||
filteredInterfaces() {
|
||||
const search = this.searchTerm.toLowerCase().trim();
|
||||
return this.interfacesWithStats
|
||||
.filter((iface) => {
|
||||
if (this.statusFilter === "enabled" && !this.isInterfaceEnabled(iface)) {
|
||||
return false;
|
||||
}
|
||||
if (this.statusFilter === "disabled" && this.isInterfaceEnabled(iface)) {
|
||||
return false;
|
||||
}
|
||||
if (this.typeFilter !== "all" && iface.type !== this.typeFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
iface._name,
|
||||
iface.type,
|
||||
iface.target_host,
|
||||
iface.target_port,
|
||||
iface.listen_ip,
|
||||
iface.listen_port,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(search);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const enabledDiff = Number(this.isInterfaceEnabled(b)) - Number(this.isInterfaceEnabled(a));
|
||||
if (enabledDiff !== 0) return enabledDiff;
|
||||
return a._name.localeCompare(b._name);
|
||||
});
|
||||
},
|
||||
sortedInterfaceTypes() {
|
||||
const types = new Set();
|
||||
this.interfacesWithStats.forEach((iface) => types.add(iface.type));
|
||||
return Array.from(types).sort();
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearInterval(this.reloadInterval);
|
||||
},
|
||||
mounted() {
|
||||
this.loadInterfaces();
|
||||
this.updateInterfaceStats();
|
||||
|
||||
// update info every few seconds
|
||||
this.reloadInterval = setInterval(() => {
|
||||
this.updateInterfaceStats();
|
||||
}, 1000);
|
||||
},
|
||||
methods: {
|
||||
relaunch() {
|
||||
ElectronUtils.relaunch();
|
||||
},
|
||||
trackInterfaceChange() {
|
||||
this.hasPendingInterfaceChanges = true;
|
||||
},
|
||||
isInterfaceEnabled: function (iface) {
|
||||
return Utils.isInterfaceEnabled(iface);
|
||||
},
|
||||
async loadInterfaces() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/reticulum/interfaces`);
|
||||
this.interfaces = response.data.interfaces;
|
||||
} catch {
|
||||
// do nothing if failed to load interfaces
|
||||
}
|
||||
},
|
||||
async updateInterfaceStats() {
|
||||
try {
|
||||
// fetch interface stats
|
||||
const response = await window.axios.get(`/api/v1/interface-stats`);
|
||||
|
||||
// update data
|
||||
const interfaces = response.data.interface_stats?.interfaces ?? [];
|
||||
for (const iface of interfaces) {
|
||||
this.interfaceStats[iface.short_name] = iface;
|
||||
}
|
||||
} catch {
|
||||
// do nothing if failed to load interfaces
|
||||
}
|
||||
},
|
||||
async enableInterface(interfaceName) {
|
||||
// enable interface
|
||||
try {
|
||||
await window.axios.post(`/api/v1/reticulum/interfaces/enable`, {
|
||||
name: interfaceName,
|
||||
});
|
||||
this.trackInterfaceChange();
|
||||
} catch (e) {
|
||||
DialogUtils.alert("failed to enable interface");
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
// reload interfaces
|
||||
await this.loadInterfaces();
|
||||
},
|
||||
async disableInterface(interfaceName) {
|
||||
// disable interface
|
||||
try {
|
||||
await window.axios.post(`/api/v1/reticulum/interfaces/disable`, {
|
||||
name: interfaceName,
|
||||
});
|
||||
this.trackInterfaceChange();
|
||||
} catch (e) {
|
||||
DialogUtils.alert("failed to disable interface");
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
// reload interfaces
|
||||
await this.loadInterfaces();
|
||||
},
|
||||
async editInterface(interfaceName) {
|
||||
this.$router.push({
|
||||
name: "interfaces.edit",
|
||||
query: {
|
||||
interface_name: interfaceName,
|
||||
},
|
||||
});
|
||||
},
|
||||
async deleteInterface(interfaceName) {
|
||||
// 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!"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// delete interface
|
||||
try {
|
||||
await window.axios.post(`/api/v1/reticulum/interfaces/delete`, {
|
||||
name: interfaceName,
|
||||
});
|
||||
this.trackInterfaceChange();
|
||||
} catch (e) {
|
||||
DialogUtils.alert("failed to delete interface");
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
// reload interfaces
|
||||
await this.loadInterfaces();
|
||||
},
|
||||
async exportInterfaces() {
|
||||
try {
|
||||
// fetch exported interfaces
|
||||
const response = await window.axios.post("/api/v1/reticulum/interfaces/export");
|
||||
this.trackInterfaceChange();
|
||||
|
||||
// download file to browser
|
||||
DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
|
||||
} catch (e) {
|
||||
DialogUtils.alert("Failed to export interfaces");
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
async exportInterface(interfaceName) {
|
||||
try {
|
||||
// fetch exported interfaces
|
||||
const response = await window.axios.post("/api/v1/reticulum/interfaces/export", {
|
||||
selected_interface_names: [interfaceName],
|
||||
});
|
||||
this.trackInterfaceChange();
|
||||
|
||||
// download file to browser
|
||||
DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
|
||||
} catch (e) {
|
||||
DialogUtils.alert("Failed to export interface");
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
showImportInterfacesModal() {
|
||||
this.$refs["import-interfaces-modal"].show();
|
||||
},
|
||||
onImportInterfacesModalDismissed(imported = false) {
|
||||
// reload interfaces as something may have been imported
|
||||
this.loadInterfaces();
|
||||
if (imported) {
|
||||
this.trackInterfaceChange();
|
||||
}
|
||||
},
|
||||
setStatusFilter(value) {
|
||||
this.statusFilter = value;
|
||||
},
|
||||
filterChipClass(isActive) {
|
||||
return isActive ? "primary-chip text-xs" : "secondary-chip text-xs";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
1314
meshchatx/src/frontend/components/map/MapPage.vue
Normal file
1314
meshchatx/src/frontend/components/map/MapPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
112
meshchatx/src/frontend/components/messages/AddAudioButton.vue
Normal file
112
meshchatx/src/frontend/components/messages/AddAudioButton.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="inline-flex">
|
||||
<button
|
||||
v-if="isRecordingAudioAttachment"
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-full border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 shadow-sm hover:border-red-400 transition dark:border-red-500/40 dark:bg-red-900/30 dark:text-red-100"
|
||||
@click="stopRecordingAudioAttachment"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="microphone" class="w-4 h-4" />
|
||||
<span class="ml-1">
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition"
|
||||
@click="showMenu"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="microphone-plus" class="w-4 h-4" />
|
||||
<span class="hidden xl:inline-block whitespace-nowrap">Add Voice</span>
|
||||
</button>
|
||||
|
||||
<div class="relative block">
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="isShowingMenu"
|
||||
v-click-outside="hideMenu"
|
||||
class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none"
|
||||
>
|
||||
<div class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
|
||||
@click="startRecordingCodec2('1200')"
|
||||
>
|
||||
Low Quality - Codec2 (1200)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
|
||||
@click="startRecordingCodec2('3200')"
|
||||
>
|
||||
Medium Quality - Codec2 (3200)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
|
||||
@click="startRecordingOpus()"
|
||||
>
|
||||
High Quality - OPUS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: "AddAudioButton",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
isRecordingAudioAttachment: Boolean,
|
||||
},
|
||||
emits: ["start-recording", "stop-recording"],
|
||||
data() {
|
||||
return {
|
||||
isShowingMenu: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
showMenu() {
|
||||
this.isShowingMenu = true;
|
||||
},
|
||||
hideMenu() {
|
||||
this.isShowingMenu = false;
|
||||
},
|
||||
startRecordingAudioAttachment(args) {
|
||||
this.isShowingMenu = false;
|
||||
this.$emit("start-recording", args);
|
||||
},
|
||||
startRecordingCodec2(mode) {
|
||||
this.startRecordingAudioAttachment({
|
||||
codec: "codec2",
|
||||
mode: mode,
|
||||
});
|
||||
},
|
||||
startRecordingOpus() {
|
||||
this.startRecordingAudioAttachment({
|
||||
codec: "opus",
|
||||
});
|
||||
},
|
||||
stopRecordingAudioAttachment() {
|
||||
this.isShowingMenu = false;
|
||||
this.$emit("stop-recording");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
164
meshchatx/src/frontend/components/messages/AddImageButton.vue
Normal file
164
meshchatx/src/frontend/components/messages/AddImageButton.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition"
|
||||
@click="showMenu"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="image-plus" class="w-4 h-4" />
|
||||
<span class="hidden xl:inline-block whitespace-nowrap">Add Image</span>
|
||||
</button>
|
||||
|
||||
<div class="relative block">
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="isShowingMenu"
|
||||
v-click-outside="hideMenu"
|
||||
class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none"
|
||||
>
|
||||
<div class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
|
||||
@click="addImage('low')"
|
||||
>
|
||||
Low Quality (320x320)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
|
||||
@click="addImage('medium')"
|
||||
>
|
||||
Medium Quality (640x640)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
|
||||
@click="addImage('high')"
|
||||
>
|
||||
High Quality (1280x1280)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
|
||||
@click="addImage('original')"
|
||||
>
|
||||
Original Quality
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- hidden file input for selecting files -->
|
||||
<input ref="image-input" type="file" accept="image/*" style="display: none" @change="onImageInputChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Compressor from "compressorjs";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: "AddImageButton",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
emits: ["add-image"],
|
||||
data() {
|
||||
return {
|
||||
isShowingMenu: false,
|
||||
selectedImageQuality: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
showMenu() {
|
||||
this.isShowingMenu = true;
|
||||
},
|
||||
hideMenu() {
|
||||
this.isShowingMenu = false;
|
||||
},
|
||||
addImage(quality) {
|
||||
this.isShowingMenu = false;
|
||||
this.selectedImageQuality = quality;
|
||||
this.$refs["image-input"].click();
|
||||
},
|
||||
clearImageInput: function () {
|
||||
this.$refs["image-input"].value = null;
|
||||
},
|
||||
onImageInputChange: async function (event) {
|
||||
if (event.target.files.length > 0) {
|
||||
// get selected file
|
||||
const file = event.target.files[0];
|
||||
|
||||
// process file based on selected image quality
|
||||
switch (this.selectedImageQuality) {
|
||||
case "low": {
|
||||
new Compressor(file, {
|
||||
maxWidth: 320,
|
||||
maxHeight: 320,
|
||||
quality: 0.2,
|
||||
mimeType: "image/webp",
|
||||
success: (result) => {
|
||||
this.$emit("add-image", result);
|
||||
},
|
||||
error: (err) => {
|
||||
DialogUtils.alert(err.message);
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "medium": {
|
||||
new Compressor(file, {
|
||||
maxWidth: 640,
|
||||
maxHeight: 640,
|
||||
quality: 0.6,
|
||||
mimeType: "image/webp",
|
||||
success: (result) => {
|
||||
this.$emit("add-image", result);
|
||||
},
|
||||
error: (err) => {
|
||||
DialogUtils.alert(err.message);
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "high": {
|
||||
new Compressor(file, {
|
||||
maxWidth: 1280,
|
||||
maxHeight: 1280,
|
||||
quality: 0.75,
|
||||
mimeType: "image/webp",
|
||||
success: (result) => {
|
||||
this.$emit("add-image", result);
|
||||
},
|
||||
error: (err) => {
|
||||
DialogUtils.alert(err.message);
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "original": {
|
||||
this.$emit("add-image", file);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
DialogUtils.alert(`Unsupported image quality: ${this.selectedImageQuality}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// clear image input to allow selecting the same file after user removed it
|
||||
this.clearImageInput();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<DropDownMenu>
|
||||
<template #button>
|
||||
<IconButton>
|
||||
<MaterialDesignIcon icon-name="dots-vertical" class="size-5" />
|
||||
</IconButton>
|
||||
</template>
|
||||
<template #items>
|
||||
<!-- call button -->
|
||||
<DropDownMenuItem @click="onStartCall">
|
||||
<MaterialDesignIcon icon-name="phone" class="w-4 h-4" />
|
||||
<span>Start a Call</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- ping button -->
|
||||
<DropDownMenuItem @click="onPingDestination">
|
||||
<MaterialDesignIcon icon-name="flash" class="size-5" />
|
||||
<span>Ping Destination</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- set custom display name button -->
|
||||
<DropDownMenuItem @click="onSetCustomDisplayName">
|
||||
<MaterialDesignIcon icon-name="account-edit" class="size-5" />
|
||||
<span>Set Custom Display Name</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- block/unblock button -->
|
||||
<div class="border-t">
|
||||
<DropDownMenuItem v-if="!isBlocked" @click="onBlockDestination">
|
||||
<MaterialDesignIcon icon-name="block-helper" class="size-5 text-red-500" />
|
||||
<span class="text-red-500">Block User</span>
|
||||
</DropDownMenuItem>
|
||||
<DropDownMenuItem v-else @click="onUnblockDestination">
|
||||
<MaterialDesignIcon icon-name="check-circle" class="size-5 text-green-500" />
|
||||
<span class="text-green-500">Unblock User</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
|
||||
<!-- delete message history button -->
|
||||
<div class="border-t">
|
||||
<DropDownMenuItem @click="onDeleteMessageHistory">
|
||||
<MaterialDesignIcon icon-name="delete" class="size-5 text-red-500" />
|
||||
<span class="text-red-500">Delete Message History</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
</template>
|
||||
</DropDownMenu>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DropDownMenu from "../DropDownMenu.vue";
|
||||
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||
import IconButton from "../IconButton.vue";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
|
||||
export default {
|
||||
name: "ConversationDropDownMenu",
|
||||
components: {
|
||||
IconButton,
|
||||
DropDownMenuItem,
|
||||
DropDownMenu,
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
peer: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["conversation-deleted", "set-custom-display-name", "block-status-changed"],
|
||||
data() {
|
||||
return {
|
||||
isBlocked: false,
|
||||
blockedDestinations: [],
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
peer: {
|
||||
handler() {
|
||||
this.checkIfBlocked();
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadBlockedDestinations();
|
||||
},
|
||||
methods: {
|
||||
async loadBlockedDestinations() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/blocked-destinations");
|
||||
this.blockedDestinations = response.data.blocked_destinations || [];
|
||||
this.checkIfBlocked();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
checkIfBlocked() {
|
||||
if (!this.peer) {
|
||||
this.isBlocked = false;
|
||||
return;
|
||||
}
|
||||
this.isBlocked = this.blockedDestinations.some((b) => b.destination_hash === this.peer.destination_hash);
|
||||
},
|
||||
async onBlockDestination() {
|
||||
if (
|
||||
!(await DialogUtils.confirm(
|
||||
"Are you sure you want to block this user? They will not be able to send you messages or establish links."
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.axios.post("/api/v1/blocked-destinations", {
|
||||
destination_hash: this.peer.destination_hash,
|
||||
});
|
||||
await this.loadBlockedDestinations();
|
||||
DialogUtils.alert("User blocked successfully");
|
||||
this.$emit("block-status-changed");
|
||||
} catch (e) {
|
||||
DialogUtils.alert("Failed to block user");
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async onUnblockDestination() {
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/blocked-destinations/${this.peer.destination_hash}`);
|
||||
await this.loadBlockedDestinations();
|
||||
DialogUtils.alert("User unblocked successfully");
|
||||
this.$emit("block-status-changed");
|
||||
} catch (e) {
|
||||
DialogUtils.alert("Failed to unblock user");
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async onDeleteMessageHistory() {
|
||||
// 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!"
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// delete all lxmf messages from "us to destination" and from "destination to us"
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/lxmf-messages/conversation/${this.peer.destination_hash}`);
|
||||
} catch (e) {
|
||||
DialogUtils.alert("failed to delete conversation");
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
// fire callback
|
||||
this.$emit("conversation-deleted");
|
||||
},
|
||||
async onSetCustomDisplayName() {
|
||||
this.$emit("set-custom-display-name");
|
||||
},
|
||||
async onStartCall() {
|
||||
try {
|
||||
await window.axios.get(`/api/v1/telephone/call/${this.peer.destination_hash}`);
|
||||
} catch (e) {
|
||||
const message = e.response?.data?.message ?? "Failed to start call";
|
||||
DialogUtils.alert(message);
|
||||
}
|
||||
},
|
||||
async onPingDestination() {
|
||||
try {
|
||||
// ping destination
|
||||
const response = await window.axios.get(`/api/v1/ping/${this.peer.destination_hash}/lxmf.delivery`, {
|
||||
params: {
|
||||
timeout: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const pingResult = response.data.ping_result;
|
||||
const rttMilliseconds = (pingResult.rtt * 1000).toFixed(3);
|
||||
const rttDurationString = `${rttMilliseconds} ms`;
|
||||
|
||||
const info = [
|
||||
`Valid reply from ${this.peer.destination_hash}`,
|
||||
`Duration: ${rttDurationString}`,
|
||||
`Hops There: ${pingResult.hops_there}`,
|
||||
`Hops Back: ${pingResult.hops_back}`,
|
||||
];
|
||||
|
||||
// add signal quality if available
|
||||
if (pingResult.quality != null) {
|
||||
info.push(`Signal Quality: ${pingResult.quality}%`);
|
||||
}
|
||||
|
||||
// add rssi if available
|
||||
if (pingResult.rssi != null) {
|
||||
info.push(`RSSI: ${pingResult.rssi}dBm`);
|
||||
}
|
||||
|
||||
// add snr if available
|
||||
if (pingResult.snr != null) {
|
||||
info.push(`SNR: ${pingResult.snr}dB`);
|
||||
}
|
||||
|
||||
// show result
|
||||
DialogUtils.alert(info.join("\n"));
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
const message = e.response?.data?.message ?? "Ping failed. Try again later";
|
||||
DialogUtils.alert(message);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
2118
meshchatx/src/frontend/components/messages/ConversationViewer.vue
Normal file
2118
meshchatx/src/frontend/components/messages/ConversationViewer.vue
Normal file
File diff suppressed because it is too large
Load Diff
338
meshchatx/src/frontend/components/messages/MessagesPage.vue
Normal file
338
meshchatx/src/frontend/components/messages/MessagesPage.vue
Normal file
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div class="flex flex-1 min-w-0 h-full overflow-hidden">
|
||||
<MessagesSidebar
|
||||
v-if="!isPopoutMode"
|
||||
:class="{ 'hidden sm:flex': destinationHash }"
|
||||
:conversations="conversations"
|
||||
:peers="peers"
|
||||
:selected-destination-hash="selectedPeer?.destination_hash"
|
||||
:conversation-search-term="conversationSearchTerm"
|
||||
:filter-unread-only="filterUnreadOnly"
|
||||
:filter-failed-only="filterFailedOnly"
|
||||
:filter-has-attachments-only="filterHasAttachmentsOnly"
|
||||
:is-loading="isLoadingConversations"
|
||||
@conversation-click="onConversationClick"
|
||||
@peer-click="onPeerClick"
|
||||
@conversation-search-changed="onConversationSearchChanged"
|
||||
@conversation-filter-changed="onConversationFilterChanged"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-white via-slate-50 to-slate-100 dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900/80"
|
||||
:class="destinationHash ? 'flex' : 'hidden sm:flex'"
|
||||
>
|
||||
<!-- messages tab -->
|
||||
<ConversationViewer
|
||||
ref="conversation-viewer"
|
||||
:my-lxmf-address-hash="config?.lxmf_address_hash"
|
||||
:selected-peer="selectedPeer"
|
||||
:conversations="conversations"
|
||||
@close="onCloseConversationViewer"
|
||||
@reload-conversations="getConversations"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
import MessagesSidebar from "./MessagesSidebar.vue";
|
||||
import ConversationViewer from "./ConversationViewer.vue";
|
||||
import GlobalState from "../../js/GlobalState";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||
|
||||
export default {
|
||||
name: "MessagesPage",
|
||||
components: {
|
||||
ConversationViewer,
|
||||
MessagesSidebar,
|
||||
},
|
||||
props: {
|
||||
destinationHash: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
reloadInterval: null,
|
||||
conversationRefreshTimeout: null,
|
||||
|
||||
config: null,
|
||||
peers: {},
|
||||
selectedPeer: null,
|
||||
|
||||
conversations: [],
|
||||
lxmfDeliveryAnnounces: [],
|
||||
|
||||
conversationSearchTerm: "",
|
||||
filterUnreadOnly: false,
|
||||
filterFailedOnly: false,
|
||||
filterHasAttachmentsOnly: false,
|
||||
isLoadingConversations: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
popoutRouteType() {
|
||||
if (this.$route?.meta?.popoutType) {
|
||||
return this.$route.meta.popoutType;
|
||||
}
|
||||
return this.$route?.query?.popout ?? this.getHashPopoutValue();
|
||||
},
|
||||
isPopoutMode() {
|
||||
return this.popoutRouteType === "conversation";
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
conversations() {
|
||||
// update global state
|
||||
GlobalState.unreadConversationsCount = this.conversations.filter((conversation) => {
|
||||
return conversation.is_unread;
|
||||
}).length;
|
||||
},
|
||||
destinationHash(newHash) {
|
||||
if (newHash) {
|
||||
this.onComposeNewMessage(newHash);
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearInterval(this.reloadInterval);
|
||||
clearTimeout(this.conversationRefreshTimeout);
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
GlobalEmitter.off("compose-new-message", this.onComposeNewMessage);
|
||||
},
|
||||
mounted() {
|
||||
// listen for websocket messages
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
GlobalEmitter.on("compose-new-message", this.onComposeNewMessage);
|
||||
|
||||
this.getConfig();
|
||||
this.getConversations();
|
||||
this.getLxmfDeliveryAnnounces();
|
||||
|
||||
// update info every few seconds
|
||||
this.reloadInterval = setInterval(() => {
|
||||
this.getConversations();
|
||||
}, 5000);
|
||||
|
||||
// compose message if a destination hash was provided on page load
|
||||
if (this.destinationHash) {
|
||||
this.onComposeNewMessage(this.destinationHash);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async onComposeNewMessage(destinationHash) {
|
||||
if (destinationHash == null) {
|
||||
if (this.selectedPeer) {
|
||||
return;
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
const composeInput = document.getElementById("compose-input");
|
||||
if (composeInput) {
|
||||
composeInput.focus();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (destinationHash.startsWith("lxmf@")) {
|
||||
destinationHash = destinationHash.replace("lxmf@", "");
|
||||
}
|
||||
|
||||
await this.getLxmfDeliveryAnnounce(destinationHash);
|
||||
|
||||
const existingPeer = this.peers[destinationHash];
|
||||
if (existingPeer) {
|
||||
this.onPeerClick(existingPeer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (destinationHash.length !== 32) {
|
||||
DialogUtils.alert("Invalid Address");
|
||||
return;
|
||||
}
|
||||
|
||||
this.onPeerClick({
|
||||
display_name: "Unknown Peer",
|
||||
destination_hash: destinationHash,
|
||||
});
|
||||
},
|
||||
async getConfig() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/config`);
|
||||
this.config = response.data.config;
|
||||
} catch (e) {
|
||||
// do nothing if failed to load config
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async onWebsocketMessage(message) {
|
||||
const json = JSON.parse(message.data);
|
||||
switch (json.type) {
|
||||
case "config": {
|
||||
this.config = json.config;
|
||||
break;
|
||||
}
|
||||
case "announce": {
|
||||
const aspect = json.announce.aspect;
|
||||
if (aspect === "lxmf.delivery") {
|
||||
this.updatePeerFromAnnounce(json.announce);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "lxmf.delivery": {
|
||||
// reload conversations when a new message is received
|
||||
await this.getConversations();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
async getLxmfDeliveryAnnounces() {
|
||||
try {
|
||||
// fetch announces for "lxmf.delivery" aspect
|
||||
const response = await window.axios.get(`/api/v1/announces`, {
|
||||
params: {
|
||||
aspect: "lxmf.delivery",
|
||||
limit: 500, // limit ui to showing 500 latest announces
|
||||
},
|
||||
});
|
||||
|
||||
// update ui
|
||||
const lxmfDeliveryAnnounces = response.data.announces;
|
||||
for (const lxmfDeliveryAnnounce of lxmfDeliveryAnnounces) {
|
||||
this.updatePeerFromAnnounce(lxmfDeliveryAnnounce);
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing if failed to load announces
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async getLxmfDeliveryAnnounce(destinationHash) {
|
||||
try {
|
||||
// fetch announce for destination hash
|
||||
const response = await window.axios.get(`/api/v1/announces`, {
|
||||
params: {
|
||||
destination_hash: destinationHash,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// update ui
|
||||
const lxmfDeliveryAnnounces = response.data.announces;
|
||||
for (const lxmfDeliveryAnnounce of lxmfDeliveryAnnounces) {
|
||||
this.updatePeerFromAnnounce(lxmfDeliveryAnnounce);
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing if failed to load announce
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async getConversations() {
|
||||
try {
|
||||
this.isLoadingConversations = true;
|
||||
const response = await window.axios.get(`/api/v1/lxmf/conversations`, {
|
||||
params: this.buildConversationQueryParams(),
|
||||
});
|
||||
this.conversations = response.data.conversations;
|
||||
} catch (e) {
|
||||
// do nothing if failed to load conversations
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.isLoadingConversations = false;
|
||||
}
|
||||
},
|
||||
buildConversationQueryParams() {
|
||||
const params = {};
|
||||
if (this.conversationSearchTerm && this.conversationSearchTerm.trim() !== "") {
|
||||
params.search = this.conversationSearchTerm.trim();
|
||||
}
|
||||
if (this.filterUnreadOnly) {
|
||||
params.filter_unread = true;
|
||||
}
|
||||
if (this.filterFailedOnly) {
|
||||
params.filter_failed = true;
|
||||
}
|
||||
if (this.filterHasAttachmentsOnly) {
|
||||
params.filter_has_attachments = true;
|
||||
}
|
||||
return params;
|
||||
},
|
||||
updatePeerFromAnnounce: function (announce) {
|
||||
this.peers[announce.destination_hash] = announce;
|
||||
},
|
||||
onPeerClick: function (peer) {
|
||||
// update selected peer
|
||||
this.selectedPeer = peer;
|
||||
|
||||
// update current route
|
||||
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
|
||||
const routeOptions = {
|
||||
name: routeName,
|
||||
params: {
|
||||
destinationHash: peer.destination_hash,
|
||||
},
|
||||
};
|
||||
if (!this.isPopoutMode && this.$route?.query) {
|
||||
routeOptions.query = { ...this.$route.query };
|
||||
}
|
||||
this.$router.replace(routeOptions);
|
||||
},
|
||||
onConversationClick: function (conversation) {
|
||||
// object must stay compatible with format of peers
|
||||
this.onPeerClick(conversation);
|
||||
|
||||
// mark conversation as read
|
||||
this.$refs["conversation-viewer"].markConversationAsRead(conversation);
|
||||
},
|
||||
onCloseConversationViewer: function () {
|
||||
// clear selected peer
|
||||
this.selectedPeer = null;
|
||||
|
||||
if (this.isPopoutMode) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// update current route
|
||||
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
|
||||
const routeOptions = { name: routeName };
|
||||
if (!this.isPopoutMode && this.$route?.query) {
|
||||
routeOptions.query = { ...this.$route.query };
|
||||
}
|
||||
this.$router.replace(routeOptions);
|
||||
},
|
||||
requestConversationsRefresh() {
|
||||
if (this.conversationRefreshTimeout) {
|
||||
clearTimeout(this.conversationRefreshTimeout);
|
||||
}
|
||||
this.conversationRefreshTimeout = setTimeout(() => {
|
||||
this.getConversations();
|
||||
}, 250);
|
||||
},
|
||||
onConversationSearchChanged(term) {
|
||||
this.conversationSearchTerm = term;
|
||||
this.requestConversationsRefresh();
|
||||
},
|
||||
onConversationFilterChanged(filterKey) {
|
||||
if (filterKey === "unread") {
|
||||
this.filterUnreadOnly = !this.filterUnreadOnly;
|
||||
} else if (filterKey === "failed") {
|
||||
this.filterFailedOnly = !this.filterFailedOnly;
|
||||
} else if (filterKey === "attachments") {
|
||||
this.filterHasAttachmentsOnly = !this.filterHasAttachmentsOnly;
|
||||
}
|
||||
this.requestConversationsRefresh();
|
||||
},
|
||||
getHashPopoutValue() {
|
||||
const hash = window.location.hash || "";
|
||||
const match = hash.match(/popout=([^&]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
436
meshchatx/src/frontend/components/messages/MessagesSidebar.vue
Normal file
436
meshchatx/src/frontend/components/messages/MessagesSidebar.vue
Normal file
@@ -0,0 +1,436 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-full sm:w-80 sm:min-w-80">
|
||||
<!-- tabs -->
|
||||
<div class="bg-transparent border-b border-r border-gray-200/70 dark:border-zinc-700/80 backdrop-blur">
|
||||
<div class="-mb-px flex">
|
||||
<div
|
||||
class="w-full border-b-2 py-3 px-1 text-center text-sm font-semibold tracking-wide uppercase cursor-pointer transition"
|
||||
:class="[
|
||||
tab === 'conversations'
|
||||
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300'
|
||||
: '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-200',
|
||||
]"
|
||||
@click="tab = 'conversations'"
|
||||
>
|
||||
{{ $t("messages.conversations") }}
|
||||
</div>
|
||||
<div
|
||||
class="w-full border-b-2 py-3 px-1 text-center text-sm font-semibold tracking-wide uppercase cursor-pointer transition"
|
||||
:class="[
|
||||
tab === 'announces'
|
||||
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300'
|
||||
: '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-200',
|
||||
]"
|
||||
@click="tab = 'announces'"
|
||||
>
|
||||
{{ $t("messages.announces") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- conversations -->
|
||||
<div
|
||||
v-if="tab === 'conversations'"
|
||||
class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0"
|
||||
>
|
||||
<!-- search + filters -->
|
||||
<div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-2">
|
||||
<input
|
||||
:value="conversationSearchTerm"
|
||||
type="text"
|
||||
:placeholder="$t('messages.search_placeholder', { count: conversations.length })"
|
||||
class="input-field"
|
||||
@input="onConversationSearchInput"
|
||||
/>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button type="button" :class="filterChipClasses(filterUnreadOnly)" @click="toggleFilter('unread')">
|
||||
{{ $t("messages.unread") }}
|
||||
</button>
|
||||
<button type="button" :class="filterChipClasses(filterFailedOnly)" @click="toggleFilter('failed')">
|
||||
{{ $t("messages.failed") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="filterChipClasses(filterHasAttachmentsOnly)"
|
||||
@click="toggleFilter('attachments')"
|
||||
>
|
||||
{{ $t("messages.attachments") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- conversations -->
|
||||
<div class="flex h-full overflow-y-auto">
|
||||
<div v-if="displayedConversations.length > 0" class="w-full">
|
||||
<div
|
||||
v-for="conversation of displayedConversations"
|
||||
:key="conversation.destination_hash"
|
||||
class="flex cursor-pointer p-2 border-l-2"
|
||||
:class="[
|
||||
conversation.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',
|
||||
]"
|
||||
@click="onConversationClick(conversation)"
|
||||
>
|
||||
<div class="my-auto mr-2">
|
||||
<div
|
||||
v-if="conversation.lxmf_user_icon"
|
||||
class="p-2 rounded"
|
||||
:style="{
|
||||
color: conversation.lxmf_user_icon.foreground_colour,
|
||||
'background-color': conversation.lxmf_user_icon.background_colour,
|
||||
}"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="conversation.lxmf_user_icon.icon_name"
|
||||
class="w-6 h-6"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mr-auto w-full pr-2 min-w-0">
|
||||
<div class="flex justify-between gap-2 min-w-0">
|
||||
<div
|
||||
class="text-gray-900 dark:text-gray-100 truncate min-w-0"
|
||||
:title="conversation.custom_display_name ?? conversation.display_name"
|
||||
:class="{
|
||||
'font-semibold':
|
||||
conversation.is_unread || conversation.failed_messages_count > 0,
|
||||
}"
|
||||
>
|
||||
{{ conversation.custom_display_name ?? conversation.display_name }}
|
||||
</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap flex-shrink-0">
|
||||
{{ formatTimeAgo(conversation.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-xs mt-0.5 truncate">
|
||||
{{
|
||||
conversation.latest_message_preview ??
|
||||
conversation.latest_message_title ??
|
||||
"No messages yet"
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
|
||||
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4" />
|
||||
</div>
|
||||
<div v-if="conversation.is_unread" class="my-auto ml-1">
|
||||
<div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
|
||||
</div>
|
||||
<div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
|
||||
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mx-auto my-auto text-center leading-5">
|
||||
<div v-if="isLoading" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div class="mx-auto mb-1 animate-spin text-gray-500">
|
||||
<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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold">Loading conversations…</div>
|
||||
</div>
|
||||
|
||||
<!-- no conversations at all -->
|
||||
<div v-else-if="conversations.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="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold">No Conversations</div>
|
||||
<div>Discover peers on the Announces tab</div>
|
||||
</div>
|
||||
|
||||
<!-- is searching, but no results -->
|
||||
<div
|
||||
v-else-if="conversationSearchTerm !== ''"
|
||||
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">{{ $t("messages.no_search_results") }}</div>
|
||||
<div>{{ $t("messages.no_search_results_conversations") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- discover -->
|
||||
<div
|
||||
v-if="tab === 'announces'"
|
||||
class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0"
|
||||
>
|
||||
<!-- search -->
|
||||
<div v-if="peersCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
|
||||
<input
|
||||
v-model="peersSearchTerm"
|
||||
type="text"
|
||||
:placeholder="$t('messages.search_placeholder_announces', { count: peersCount })"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- peers -->
|
||||
<div class="flex h-full overflow-y-auto">
|
||||
<div v-if="searchedPeers.length > 0" class="w-full">
|
||||
<div
|
||||
v-for="peer of searchedPeers"
|
||||
:key="peer.destination_hash"
|
||||
class="flex cursor-pointer p-2 border-l-2"
|
||||
:class="[
|
||||
peer.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',
|
||||
]"
|
||||
@click="onPeerClick(peer)"
|
||||
>
|
||||
<div class="my-auto mr-2">
|
||||
<div
|
||||
v-if="peer.lxmf_user_icon"
|
||||
class="p-2 rounded"
|
||||
:style="{
|
||||
color: peer.lxmf_user_icon.foreground_colour,
|
||||
'background-color': peer.lxmf_user_icon.background_colour,
|
||||
}"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="peer.lxmf_user_icon.icon_name" class="w-6 h-6" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="text-gray-900 dark:text-gray-100 truncate"
|
||||
:title="peer.custom_display_name ?? peer.display_name"
|
||||
>
|
||||
{{ peer.custom_display_name ?? peer.display_name }}
|
||||
</div>
|
||||
<div class="flex space-x-1 text-gray-500 dark:text-gray-400 text-sm">
|
||||
<!-- time ago -->
|
||||
<span class="flex my-auto space-x-1">
|
||||
{{ formatTimeAgo(peer.updated_at) }}
|
||||
</span>
|
||||
|
||||
<!-- hops away -->
|
||||
<span
|
||||
v-if="peer.hops != null && peer.hops !== 128"
|
||||
class="flex my-auto text-sm text-gray-500 space-x-1"
|
||||
>
|
||||
<span>•</span>
|
||||
<span v-if="peer.hops === 0 || peer.hops === 1">{{ $t("messages.direct") }}</span>
|
||||
<span v-else>{{ $t("messages.hops", { count: peer.hops }) }}</span>
|
||||
</span>
|
||||
|
||||
<!-- snr -->
|
||||
<span v-if="peer.snr != null" class="flex my-auto space-x-1">
|
||||
<span>•</span>
|
||||
<span>{{ $t("messages.snr", { snr: peer.snr }) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mx-auto my-auto text-center leading-5">
|
||||
<!-- no peers at all -->
|
||||
<div v-if="peersCount === 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="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold">{{ $t("messages.no_peers_discovered") }}</div>
|
||||
<div>{{ $t("messages.waiting_for_announce") }}</div>
|
||||
</div>
|
||||
|
||||
<!-- is searching, but no results -->
|
||||
<div
|
||||
v-if="peersSearchTerm !== '' && peersCount > 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">{{ $t("messages.no_search_results") }}</div>
|
||||
<div>{{ $t("messages.no_search_results_peers") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Utils from "../../js/Utils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "MessagesSidebar",
|
||||
components: { MaterialDesignIcon },
|
||||
props: {
|
||||
peers: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
conversations: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
selectedDestinationHash: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
conversationSearchTerm: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
filterUnreadOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterFailedOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterHasAttachmentsOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["conversation-click", "peer-click", "conversation-search-changed", "conversation-filter-changed"],
|
||||
data() {
|
||||
return {
|
||||
tab: "conversations",
|
||||
peersSearchTerm: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
displayedConversations() {
|
||||
return this.conversations;
|
||||
},
|
||||
peersCount() {
|
||||
return Object.keys(this.peers).length;
|
||||
},
|
||||
peersOrderedByLatestAnnounce() {
|
||||
const peers = Object.values(this.peers);
|
||||
return peers.sort(function (peerA, peerB) {
|
||||
// order by updated_at desc
|
||||
const peerAUpdatedAt = new Date(peerA.updated_at).getTime();
|
||||
const peerBUpdatedAt = new Date(peerB.updated_at).getTime();
|
||||
return peerBUpdatedAt - peerAUpdatedAt;
|
||||
});
|
||||
},
|
||||
searchedPeers() {
|
||||
return this.peersOrderedByLatestAnnounce.filter((peer) => {
|
||||
const search = this.peersSearchTerm.toLowerCase();
|
||||
const matchesDisplayName = peer.display_name.toLowerCase().includes(search);
|
||||
const matchesCustomDisplayName = peer.custom_display_name?.toLowerCase()?.includes(search) === true;
|
||||
const matchesDestinationHash = peer.destination_hash.toLowerCase().includes(search);
|
||||
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onConversationClick(conversation) {
|
||||
this.$emit("conversation-click", conversation);
|
||||
},
|
||||
onPeerClick(peer) {
|
||||
this.$emit("peer-click", peer);
|
||||
},
|
||||
formatTimeAgo: function (datetimeString) {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
},
|
||||
onConversationSearchInput(event) {
|
||||
this.$emit("conversation-search-changed", event.target.value);
|
||||
},
|
||||
toggleFilter(filterKey) {
|
||||
this.$emit("conversation-filter-changed", filterKey);
|
||||
},
|
||||
filterChipClasses(isActive) {
|
||||
const base = "px-2 py-1 rounded-full text-xs font-semibold transition-colors";
|
||||
if (isActive) {
|
||||
return `${base} bg-blue-600 text-white dark:bg-blue-500`;
|
||||
}
|
||||
return `${base} bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-200`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
152
meshchatx/src/frontend/components/messages/SendMessageButton.vue
Normal file
152
meshchatx/src/frontend/components/messages/SendMessageButton.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-stretch rounded-xl shadow-sm">
|
||||
<!-- send button -->
|
||||
<button
|
||||
:disabled="!canSendMessage"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-l-xl px-4 py-2.5 text-sm font-semibold text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
:class="[
|
||||
canSendMessage
|
||||
? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500'
|
||||
: 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 cursor-not-allowed',
|
||||
]"
|
||||
@click="send"
|
||||
>
|
||||
<svg
|
||||
v-if="!isSendingMessage"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
|
||||
/>
|
||||
</svg>
|
||||
<span v-if="isSendingMessage" class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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>
|
||||
Sending...
|
||||
</span>
|
||||
<span v-else>
|
||||
<span v-if="deliveryMethod === 'direct'">Send (Direct)</span>
|
||||
<span v-else-if="deliveryMethod === 'opportunistic'">Send (Opportunistic)</span>
|
||||
<span v-else-if="deliveryMethod === 'propagated'">Send (Propagated)</span>
|
||||
<span v-else>Send</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="relative self-stretch">
|
||||
<!-- dropdown button -->
|
||||
<button
|
||||
:disabled="!canSendMessage"
|
||||
type="button"
|
||||
class="border-l relative inline-flex items-center justify-center rounded-r-xl px-2.5 h-full text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
:class="[
|
||||
canSendMessage
|
||||
? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500 border-blue-700 dark:border-blue-800'
|
||||
: 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 border-gray-500 dark:border-zinc-600 cursor-not-allowed',
|
||||
]"
|
||||
@click="showMenu"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- dropdown menu -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="isShowingMenu"
|
||||
v-click-outside="hideMenu"
|
||||
class="absolute bottom-full right-0 mb-1 z-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none overflow-hidden min-w-[200px]"
|
||||
>
|
||||
<div class="py-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap border-b border-gray-100 dark:border-zinc-800"
|
||||
@click="setDeliveryMethod(null)"
|
||||
>
|
||||
Send Automatically
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
|
||||
@click="setDeliveryMethod('direct')"
|
||||
>
|
||||
Send over Direct Link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
|
||||
@click="setDeliveryMethod('opportunistic')"
|
||||
>
|
||||
Send Opportunistically
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
|
||||
@click="setDeliveryMethod('propagated')"
|
||||
>
|
||||
Send to Propagation Node
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "SendMessageButton",
|
||||
props: {
|
||||
deliveryMethod: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
canSendMessage: Boolean,
|
||||
isSendingMessage: Boolean,
|
||||
},
|
||||
emits: ["delivery-method-changed", "send"],
|
||||
data() {
|
||||
return {
|
||||
isShowingMenu: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
showMenu() {
|
||||
this.isShowingMenu = true;
|
||||
},
|
||||
hideMenu() {
|
||||
this.isShowingMenu = false;
|
||||
},
|
||||
setDeliveryMethod(deliveryMethod) {
|
||||
this.$emit("delivery-method-changed", deliveryMethod);
|
||||
this.hideMenu();
|
||||
},
|
||||
send() {
|
||||
this.$emit("send");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,812 @@
|
||||
<template>
|
||||
<div class="flex-1 h-full min-w-0 relative dark:bg-zinc-950 overflow-hidden">
|
||||
<!-- network -->
|
||||
<div id="network" class="w-full h-full"></div>
|
||||
|
||||
<!-- loading overlay -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 z-20 flex items-center justify-center bg-zinc-950/10 backdrop-blur-[2px] transition-all duration-300"
|
||||
>
|
||||
<div
|
||||
class="bg-white/90 dark:bg-zinc-900/90 border border-gray-200 dark:border-zinc-800 rounded-2xl px-6 py-4 flex flex-col items-center gap-3"
|
||||
>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
|
||||
></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="w-6 h-6 border-4 border-emerald-500/20 border-b-emerald-500 rounded-full animate-spin-reverse"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-zinc-100">{{ loadingStatus }}</div>
|
||||
<div
|
||||
v-if="totalNodesToLoad > 0"
|
||||
class="w-48 h-1.5 bg-gray-200 dark:bg-zinc-800 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all duration-300"
|
||||
:style="{ width: `${(loadedNodesCount / totalNodesToLoad) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- controls & search -->
|
||||
<div
|
||||
class="absolute top-2 left-2 right-2 sm:top-4 sm:left-4 sm:right-4 z-10 flex flex-col sm:flex-row gap-2 pointer-events-none"
|
||||
>
|
||||
<!-- header glass card -->
|
||||
<div
|
||||
class="pointer-events-auto border border-gray-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl rounded-2xl overflow-hidden w-full sm:min-w-[280px] sm:w-auto transition-all duration-300"
|
||||
>
|
||||
<div
|
||||
class="flex items-center px-4 sm:px-5 py-3 sm:py-4 cursor-pointer hover:bg-gray-50/50 dark:hover:bg-zinc-800/50 transition-colors"
|
||||
@click="isShowingControls = !isShowingControls"
|
||||
>
|
||||
<div class="flex-1 flex flex-col min-w-0 mr-2">
|
||||
<span class="font-bold text-gray-900 dark:text-zinc-100 tracking-tight truncate"
|
||||
>Reticulum Mesh</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] uppercase font-bold text-gray-500 dark:text-zinc-500 tracking-widest truncate"
|
||||
>Network Visualizer</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white transition-all active:scale-95"
|
||||
:disabled="isUpdating || isLoading"
|
||||
@click.stop="manualUpdate"
|
||||
>
|
||||
<svg
|
||||
v-if="!isUpdating && !isLoading"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4 sm:w-5 sm:h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="animate-spin h-4 w-4 sm:h-5 sm:w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<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>
|
||||
</button>
|
||||
<div class="w-5 sm:w-6 flex justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 transition-transform duration-300"
|
||||
:class="{ 'rotate-180': isShowingControls }"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="isShowingControls"
|
||||
class="px-5 pb-5 space-y-4 animate-in fade-in slide-in-from-top-2 duration-300"
|
||||
>
|
||||
<!-- divider -->
|
||||
<div
|
||||
class="h-px bg-gradient-to-r from-transparent via-gray-200 dark:via-zinc-800 to-transparent"
|
||||
></div>
|
||||
|
||||
<!-- auto update toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<label
|
||||
for="auto-reload"
|
||||
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
|
||||
>Auto Update</label
|
||||
>
|
||||
<Toggle id="auto-reload" v-model="autoReload" />
|
||||
</div>
|
||||
|
||||
<!-- physics toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<label
|
||||
for="enable-physics"
|
||||
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
|
||||
>Live Layout</label
|
||||
>
|
||||
<Toggle id="enable-physics" v-model="enablePhysics" />
|
||||
</div>
|
||||
|
||||
<!-- stats -->
|
||||
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||
<div
|
||||
class="bg-gray-50/50 dark:bg-zinc-800/50 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
|
||||
>
|
||||
<div
|
||||
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
|
||||
>
|
||||
Nodes
|
||||
</div>
|
||||
<div class="text-lg font-bold text-blue-600 dark:text-blue-400">{{ nodes.length }}</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-gray-50/50 dark:bg-zinc-800/50 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
|
||||
>
|
||||
<div
|
||||
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
|
||||
>
|
||||
Links
|
||||
</div>
|
||||
<div class="text-lg font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{{ edges.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-zinc-950/5 dark:bg-white/5 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
|
||||
>
|
||||
<div
|
||||
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Interfaces
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-emerald-500"></div>
|
||||
<span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
|
||||
>{{ onlineInterfaces.length }} Online</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-red-500"></div>
|
||||
<span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
|
||||
>{{ offlineInterfaces.length }} Offline</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- search box -->
|
||||
<div class="sm:ml-auto w-full sm:w-auto pointer-events-auto">
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM2.25 10a7.75 7.75 0 1 1 14.03 4.5l3.47 3.47a.75.75 0 0 1-1.06 1.06l-3.47-3.47A7.75 7.75 0 0 1 2.25 10Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="`Search nodes (${nodes.length})...`"
|
||||
class="block w-full sm:w-64 pl-9 pr-10 py-2.5 sm:py-3 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl border border-gray-200/50 dark:border-zinc-800/50 rounded-2xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500/50 sm:focus:w-80 transition-all dark:text-zinc-100 shadow-sm"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-zinc-200 transition-colors"
|
||||
@click="searchQuery = ''"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- navigation breadcrumb style legend -->
|
||||
<div
|
||||
class="absolute bottom-4 right-4 z-10 hidden sm:flex items-center gap-2 px-4 py-2 rounded-full border border-gray-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3 h-3 rounded-full border-2 border-emerald-500 bg-emerald-500/20"></div>
|
||||
<span class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase">Direct</span>
|
||||
</div>
|
||||
<div class="w-px h-3 bg-gray-200 dark:bg-zinc-800 mx-1"></div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3 h-3 rounded-full border-2 border-blue-500/50 bg-blue-500/10"></div>
|
||||
<span class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase">Multi-Hop</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "vis-network/styles/vis-network.css";
|
||||
import { Network } from "vis-network";
|
||||
import { DataSet } from "vis-data";
|
||||
import * as mdi from "@mdi/js";
|
||||
import Utils from "../../js/Utils";
|
||||
import Toggle from "../forms/Toggle.vue";
|
||||
|
||||
export default {
|
||||
name: "NetworkVisualiser",
|
||||
components: {
|
||||
Toggle,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config: null,
|
||||
autoReload: false,
|
||||
reloadInterval: null,
|
||||
isShowingControls: true,
|
||||
isUpdating: false,
|
||||
isLoading: false,
|
||||
enablePhysics: true,
|
||||
loadingStatus: "Initializing...",
|
||||
loadedNodesCount: 0,
|
||||
totalNodesToLoad: 0,
|
||||
|
||||
interfaces: [],
|
||||
pathTable: [],
|
||||
announces: {},
|
||||
conversations: {},
|
||||
|
||||
network: null,
|
||||
nodes: new DataSet(),
|
||||
edges: new DataSet(),
|
||||
iconCache: {},
|
||||
|
||||
pageSize: 100,
|
||||
searchQuery: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
onlineInterfaces() {
|
||||
return this.interfaces.filter((i) => i.status);
|
||||
},
|
||||
offlineInterfaces() {
|
||||
return this.interfaces.filter((i) => !i.status);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
enablePhysics(val) {
|
||||
if (this.network) {
|
||||
this.network.setOptions({ physics: { enabled: val } });
|
||||
}
|
||||
},
|
||||
searchQuery() {
|
||||
// we don't want to trigger a full update from server, just re-run the filtering on existing data
|
||||
this.processVisualization();
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearInterval(this.reloadInterval);
|
||||
if (this.network) {
|
||||
this.network.destroy();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const isMobile = window.innerWidth < 640;
|
||||
if (isMobile) {
|
||||
this.isShowingControls = false;
|
||||
}
|
||||
this.init();
|
||||
},
|
||||
methods: {
|
||||
async getInterfaceStats() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/interface-stats`);
|
||||
this.interfaces = response.data.interface_stats?.interfaces ?? [];
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch interface stats", e);
|
||||
}
|
||||
},
|
||||
async getPathTableBatch() {
|
||||
this.pathTable = [];
|
||||
let offset = 0;
|
||||
let totalCount = 1; // dummy initial value
|
||||
|
||||
while (offset < totalCount) {
|
||||
this.loadingStatus = `Loading Paths (${offset} / ${totalCount === 1 ? "..." : totalCount})`;
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/path-table`, {
|
||||
params: { limit: this.pageSize, offset: offset },
|
||||
});
|
||||
this.pathTable.push(...response.data.path_table);
|
||||
totalCount = response.data.total_count;
|
||||
offset += this.pageSize;
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch path table batch", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
async getAnnouncesBatch() {
|
||||
this.announces = {};
|
||||
let offset = 0;
|
||||
let totalCount = 1;
|
||||
|
||||
while (offset < totalCount) {
|
||||
this.loadingStatus = `Loading Announces (${offset} / ${totalCount === 1 ? "..." : totalCount})`;
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/announces`, {
|
||||
params: { limit: this.pageSize, offset: offset },
|
||||
});
|
||||
|
||||
for (const announce of response.data.announces) {
|
||||
this.announces[announce.destination_hash] = announce;
|
||||
}
|
||||
|
||||
totalCount = response.data.total_count;
|
||||
offset += this.pageSize;
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch announces batch", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
async getConfig() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/config");
|
||||
this.config = response.data.config;
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch config", e);
|
||||
}
|
||||
},
|
||||
async getConversations() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
|
||||
this.conversations = {};
|
||||
for (const conversation of response.data.conversations) {
|
||||
this.conversations[conversation.destination_hash] = conversation;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch conversations", e);
|
||||
}
|
||||
},
|
||||
async createIconImage(iconName, foregroundColor, backgroundColor, size = 64) {
|
||||
const cacheKey = `${iconName}-${foregroundColor}-${backgroundColor}-${size}`;
|
||||
if (this.iconCache[cacheKey]) {
|
||||
return this.iconCache[cacheKey];
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
// draw background circle
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, size);
|
||||
gradient.addColorStop(0, backgroundColor);
|
||||
// slightly darken the bottom for depth
|
||||
gradient.addColorStop(1, backgroundColor);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, size / 2 - 2, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
// stroke
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.1)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// load MDI icon SVG
|
||||
const iconSvg = this.getMdiIconSvg(iconName, foregroundColor);
|
||||
const img = new Image();
|
||||
const svgBlob = new Blob([iconSvg], { type: "image/svg+xml" });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, size * 0.25, size * 0.25, size * 0.5, size * 0.5);
|
||||
URL.revokeObjectURL(url);
|
||||
const dataUrl = canvas.toDataURL();
|
||||
this.iconCache[cacheKey] = dataUrl;
|
||||
resolve(dataUrl);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const dataUrl = canvas.toDataURL();
|
||||
this.iconCache[cacheKey] = dataUrl;
|
||||
resolve(dataUrl);
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
},
|
||||
getMdiIconSvg(iconName, foregroundColor) {
|
||||
const mdiIconName =
|
||||
"mdi" +
|
||||
iconName
|
||||
.split("-")
|
||||
.map((word) => {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
})
|
||||
.join("");
|
||||
|
||||
const iconPath = mdi[mdiIconName] || mdi["mdiAccountOutline"];
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${foregroundColor}" d="${iconPath}"/></svg>`;
|
||||
},
|
||||
async init() {
|
||||
const container = document.getElementById("network");
|
||||
const isDarkMode = document.documentElement.classList.contains("dark");
|
||||
|
||||
this.network = new Network(
|
||||
container,
|
||||
{
|
||||
nodes: this.nodes,
|
||||
edges: this.edges,
|
||||
},
|
||||
{
|
||||
interaction: {
|
||||
tooltipDelay: 100,
|
||||
hover: true,
|
||||
hideEdgesOnDrag: true,
|
||||
hideEdgesOnZoom: true,
|
||||
},
|
||||
layout: {
|
||||
randomSeed: 42,
|
||||
improvedLayout: false, // faster for large networks
|
||||
},
|
||||
physics: {
|
||||
enabled: this.enablePhysics,
|
||||
solver: "barnesHut",
|
||||
barnesHut: {
|
||||
gravitationalConstant: -8000,
|
||||
springConstant: 0.04,
|
||||
springLength: 150,
|
||||
damping: 0.09,
|
||||
avoidOverlap: 0.5,
|
||||
},
|
||||
stabilization: {
|
||||
enabled: true,
|
||||
iterations: 100,
|
||||
updateInterval: 25,
|
||||
},
|
||||
},
|
||||
nodes: {
|
||||
borderWidth: 2,
|
||||
borderWidthSelected: 4,
|
||||
font: {
|
||||
face: "Inter, system-ui, sans-serif",
|
||||
strokeWidth: 4,
|
||||
strokeColor: isDarkMode ? "rgba(9, 9, 11, 0.95)" : "rgba(255, 255, 255, 0.95)",
|
||||
},
|
||||
shadow: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
edges: {
|
||||
smooth: {
|
||||
type: "continuous",
|
||||
roundness: 0.5,
|
||||
},
|
||||
selectionWidth: 4,
|
||||
hoverWidth: 3,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.network.on("doubleClick", (params) => {
|
||||
const clickedNodeId = params.nodes[0];
|
||||
if (!clickedNodeId) return;
|
||||
|
||||
const node = this.nodes.get(clickedNodeId);
|
||||
if (!node || !node._announce) return;
|
||||
|
||||
const announce = node._announce;
|
||||
if (announce.aspect === "lxmf.delivery") {
|
||||
this.$router.push({
|
||||
name: "messages",
|
||||
params: { destinationHash: announce.destination_hash },
|
||||
});
|
||||
} else if (announce.aspect === "nomadnetwork.node") {
|
||||
this.$router.push({
|
||||
name: "nomadnetwork",
|
||||
params: { destinationHash: announce.destination_hash },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await this.manualUpdate();
|
||||
|
||||
// auto reload
|
||||
this.reloadInterval = setInterval(this.onAutoReload, 15000);
|
||||
},
|
||||
async manualUpdate() {
|
||||
if (this.isLoading) return;
|
||||
this.isLoading = true;
|
||||
this.isUpdating = true;
|
||||
try {
|
||||
await this.update();
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.isUpdating = false;
|
||||
}
|
||||
},
|
||||
async onAutoReload() {
|
||||
if (!this.autoReload || this.isUpdating || this.isLoading) return;
|
||||
this.isUpdating = true;
|
||||
try {
|
||||
await this.update();
|
||||
} finally {
|
||||
this.isUpdating = false;
|
||||
}
|
||||
},
|
||||
async update() {
|
||||
this.loadingStatus = "Fetching basic info...";
|
||||
await this.getConfig();
|
||||
await this.getInterfaceStats();
|
||||
await this.getConversations();
|
||||
|
||||
this.loadingStatus = "Fetching network table...";
|
||||
await this.getPathTableBatch();
|
||||
|
||||
this.loadingStatus = "Fetching node data...";
|
||||
await this.getAnnouncesBatch();
|
||||
|
||||
await this.processVisualization();
|
||||
},
|
||||
async processVisualization() {
|
||||
this.loadingStatus = "Processing visualization...";
|
||||
|
||||
const newNodes = [];
|
||||
const newEdges = [];
|
||||
|
||||
const isDarkMode = document.documentElement.classList.contains("dark");
|
||||
const fontColor = isDarkMode ? "#ffffff" : "#000000";
|
||||
|
||||
// search filter helper
|
||||
const searchLower = this.searchQuery.toLowerCase();
|
||||
const matchesSearch = (text) => !this.searchQuery || (text && text.toLowerCase().includes(searchLower));
|
||||
|
||||
// Add me
|
||||
const meLabel = this.config?.display_name ?? "Local Node";
|
||||
if (matchesSearch(meLabel) || matchesSearch(this.config?.identity_hash)) {
|
||||
newNodes.push({
|
||||
id: "me",
|
||||
group: "me",
|
||||
size: 50,
|
||||
shape: "circularImage",
|
||||
image: "/assets/images/reticulum_logo_512.png",
|
||||
label: meLabel,
|
||||
title: `Local Node: ${meLabel}\nIdentity: ${this.config?.identity_hash ?? "Unknown"}`,
|
||||
color: { border: "#3b82f6", background: isDarkMode ? "#1e3a8a" : "#dbeafe" },
|
||||
font: { color: fontColor, size: 16, bold: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Add interfaces
|
||||
for (const entry of this.interfaces) {
|
||||
let label = entry.interface_name ?? entry.name;
|
||||
if (entry.type === "LocalServerInterface" || entry.parent_interface_name != null) {
|
||||
label = entry.name;
|
||||
}
|
||||
|
||||
if (matchesSearch(label) || matchesSearch(entry.name)) {
|
||||
newNodes.push({
|
||||
id: entry.name,
|
||||
group: "interface",
|
||||
label: label,
|
||||
title: `${entry.name}\nState: ${entry.status ? "Online" : "Offline"}\nBitrate: ${Utils.formatBitsPerSecond(entry.bitrate)}\nTX: ${Utils.formatBytes(entry.txb)}\nRX: ${Utils.formatBytes(entry.rxb)}`,
|
||||
size: 35,
|
||||
shape: "circularImage",
|
||||
image: entry.status
|
||||
? "/assets/images/network-visualiser/interface_connected.png"
|
||||
: "/assets/images/network-visualiser/interface_disconnected.png",
|
||||
color: { border: entry.status ? "#10b981" : "#ef4444" },
|
||||
font: { color: fontColor, size: 12 },
|
||||
});
|
||||
|
||||
newEdges.push({
|
||||
id: `me~${entry.name}`,
|
||||
from: "me",
|
||||
to: entry.name,
|
||||
color: entry.status ? (isDarkMode ? "#065f46" : "#10b981") : isDarkMode ? "#7f1d1d" : "#ef4444",
|
||||
width: 3,
|
||||
length: 200,
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process path table in batches to prevent UI block
|
||||
this.totalNodesToLoad = this.pathTable.length;
|
||||
this.loadedNodesCount = 0;
|
||||
|
||||
const aspectsToShow = ["lxmf.delivery", "nomadnetwork.node"];
|
||||
|
||||
// Process in chunks of 50
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < this.pathTable.length; i += chunkSize) {
|
||||
const chunk = this.pathTable.slice(i, i + chunkSize);
|
||||
|
||||
for (const entry of chunk) {
|
||||
this.loadedNodesCount++;
|
||||
if (entry.hops == null) continue;
|
||||
|
||||
const announce = this.announces[entry.hash];
|
||||
if (!announce || !aspectsToShow.includes(announce.aspect)) continue;
|
||||
|
||||
const displayName = announce.custom_display_name ?? announce.display_name;
|
||||
if (
|
||||
!matchesSearch(displayName) &&
|
||||
!matchesSearch(announce.destination_hash) &&
|
||||
!matchesSearch(announce.identity_hash)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const conversation = this.conversations[announce.destination_hash];
|
||||
const node = {
|
||||
id: entry.hash,
|
||||
group: "announce",
|
||||
size: 25,
|
||||
_announce: announce,
|
||||
font: { color: fontColor, size: 11 },
|
||||
};
|
||||
|
||||
node.label = displayName;
|
||||
node.title = `${displayName}\nAspect: ${announce.aspect}\nHops: ${entry.hops}\nVia: ${entry.interface}\nLast Seen: ${Utils.convertDateTimeToLocalDateTimeString(new Date(announce.updated_at))}`;
|
||||
|
||||
if (announce.aspect === "lxmf.delivery") {
|
||||
if (conversation?.lxmf_user_icon) {
|
||||
node.shape = "circularImage";
|
||||
node.image = await this.createIconImage(
|
||||
conversation.lxmf_user_icon.icon_name,
|
||||
conversation.lxmf_user_icon.foreground_colour,
|
||||
conversation.lxmf_user_icon.background_colour,
|
||||
64
|
||||
);
|
||||
node.size = 30;
|
||||
} else {
|
||||
node.shape = "circularImage";
|
||||
node.image =
|
||||
entry.hops === 1
|
||||
? "/assets/images/network-visualiser/user_1hop.png"
|
||||
: "/assets/images/network-visualiser/user.png";
|
||||
}
|
||||
node.color = { border: entry.hops === 1 ? "#10b981" : "#3b82f6" };
|
||||
} else if (announce.aspect === "nomadnetwork.node") {
|
||||
node.shape = "circularImage";
|
||||
node.image =
|
||||
entry.hops === 1
|
||||
? "/assets/images/network-visualiser/server_1hop.png"
|
||||
: "/assets/images/network-visualiser/server.png";
|
||||
node.color = { border: entry.hops === 1 ? "#10b981" : "#8b5cf6" };
|
||||
}
|
||||
|
||||
newNodes.push(node);
|
||||
newEdges.push({
|
||||
id: `${entry.interface}~${entry.hash}`,
|
||||
from: entry.interface,
|
||||
to: entry.hash,
|
||||
color:
|
||||
entry.hops === 1
|
||||
? isDarkMode
|
||||
? "#065f46"
|
||||
: "#10b981"
|
||||
: isDarkMode
|
||||
? "#1e3a8a"
|
||||
: "#3b82f6",
|
||||
width: entry.hops === 1 ? 2 : 1,
|
||||
dashes: entry.hops > 1,
|
||||
opacity: entry.hops === 1 ? 1 : 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
// Allow UI to breathe
|
||||
if (i % 100 === 0) {
|
||||
this.loadingStatus = `Processing Visualization (${this.loadedNodesCount} / ${this.totalNodesToLoad})...`;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
|
||||
this.processNewNodes(newNodes);
|
||||
this.processNewEdges(newEdges);
|
||||
this.totalNodesToLoad = 0;
|
||||
this.loadedNodesCount = 0;
|
||||
},
|
||||
processNewNodes(newNodes) {
|
||||
const oldNodeIds = this.nodes.getIds();
|
||||
const newNodeIds = newNodes.map((n) => n.id);
|
||||
const newNodeIdsSet = new Set(newNodeIds);
|
||||
|
||||
// remove old
|
||||
const toRemove = oldNodeIds.filter((id) => !newNodeIdsSet.has(id));
|
||||
if (toRemove.length > 0) this.nodes.remove(toRemove);
|
||||
|
||||
// update/add
|
||||
this.nodes.update(newNodes);
|
||||
},
|
||||
processNewEdges(newEdges) {
|
||||
const oldEdgeIds = this.edges.getIds();
|
||||
const newEdgeIds = newEdges.map((e) => e.id);
|
||||
const newEdgeIdsSet = new Set(newEdgeIds);
|
||||
|
||||
// remove old
|
||||
const toRemove = oldEdgeIds.filter((id) => !newEdgeIdsSet.has(id));
|
||||
if (toRemove.length > 0) this.edges.remove(toRemove);
|
||||
|
||||
// update/add
|
||||
this.edges.update(newEdges);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.vis-network:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vis-tooltip {
|
||||
color: #f4f4f5 !important;
|
||||
background: rgba(9, 9, 11, 0.9) !important;
|
||||
border: 1px solid rgba(63, 63, 70, 0.5) !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 12px 16px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
font-style: normal !important;
|
||||
font-family: Inter, system-ui, sans-serif !important;
|
||||
line-height: 1.5 !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
#network {
|
||||
background-color: #f8fafc;
|
||||
background-image: radial-gradient(#e2e8f0 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
.dark #network {
|
||||
background-color: #09090b;
|
||||
background-image: radial-gradient(#18181b 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
@keyframes spin-reverse {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-reverse {
|
||||
animation: spin-reverse 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<NetworkVisualiser />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NetworkVisualiser from "./NetworkVisualiser.vue";
|
||||
|
||||
export default {
|
||||
name: "NetworkVisualiserPage",
|
||||
components: {
|
||||
NetworkVisualiser,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
1427
meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
Normal file
1427
meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user