This commit is contained in:
2026-01-01 15:05:29 -06:00
parent 65044a54ef
commit 716007802e
147 changed files with 40416 additions and 27 deletions

77
.dockerignore Normal file
View 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

View 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
View 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/

View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
dist
node_modules
build
electron/assets
meshchatx/public
pnpm-lock.yaml
poetry.lock
*.log

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"tabWidth": 4,
"singleQuote": false,
"printWidth": 120,
"trailingComma": "es5",
"endOfLine": "auto"
}

46
Dockerfile Normal file
View 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
View 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.

View File

@@ -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
View 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
View 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
View 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:

View 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`.

View 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.

View 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`

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

File diff suppressed because one or more lines are too long

BIN
electron/build/icon.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

161
electron/loading.html Normal file
View 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
View 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
View 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
View 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
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
logo/logo-chat-bubble.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
logo/logo.afdesign Normal file
View File

Binary file not shown.

BIN
logo/logo.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

3
meshchatx/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Reticulum MeshChatX - A mesh network communications app."""
__version__ = "2.50.0"

7025
meshchatx/meshchat.py Normal file
View File

File diff suppressed because it is too large Load Diff

29
meshchatx/src/__init__.py Normal file
View 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)

View File

@@ -0,0 +1 @@
"""Backend utilities shared by the Reticulum MeshChatX CLI."""

View 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}")

View 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)

View 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

View 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.")

View 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)

View 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))

View 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()

View 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,))

View 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,))

View 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

View 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")

View 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,))

View 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)")

View 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)))

View 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,),
)

View 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

View 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

View 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)

View 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

View 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

View File

@@ -0,0 +1 @@
"""Shared transport interfaces for MeshChatX."""

View 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

View 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

View 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)

View 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

View 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"),
}

View 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(),
}

View File

@@ -0,0 +1,3 @@
# https://github.com/markqvist/Sideband/blob/e515889e210037f881c201e0d627a7b09a48eb69/sbapp/sideband/sense.py#L11
class SidebandCommands:
TELEMETRY_REQUEST = 0x01

View 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

View 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)

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View File

@@ -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>

View File

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -0,0 +1,14 @@
<template>
<NetworkVisualiser />
</template>
<script>
import NetworkVisualiser from "./NetworkVisualiser.vue";
export default {
name: "NetworkVisualiserPage",
components: {
NetworkVisualiser,
},
};
</script>

View 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