Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62d6cce914 | |||
| ab6099b0fb | |||
| d97676ad27 | |||
| 4200e43618 | |||
| c5ae53bf55 | |||
| bf8c22c31a | |||
| 9a9022ffb0 | |||
| 0443734ee3 | |||
| 6efac94f58 | |||
| 9e1a8ce180 | |||
| f2ab1ad067 | |||
| 365531be9b | |||
| 96f4fc8735 | |||
| d69a3e8522 | |||
| c95d2fd71c | |||
|
a74a6869ea
|
|||
| d8419990b1 | |||
|
085385a182
|
|||
|
f8b0dd18c5
|
|||
|
3231afb84d
|
|||
|
3848613a41
|
|||
|
284517bdfa
|
|||
|
5fc13dc61a
|
|||
|
f989295773
|
|||
|
|
d06ede8c5e | ||
|
a0047ea8fb
|
|||
|
c98131f76b
|
|||
|
9b4b8fdfeb
|
|||
|
48a0d8697e
|
|||
|
5627ae1640
|
|||
|
94d91c4934
|
|||
|
ac839df357
|
|||
|
cfad1ddc5f
|
|||
|
398ab570df
|
|||
|
50bc2cbfc8
|
|||
|
fe3a01c3c6
|
|||
|
0b0a39ea86
|
|||
|
2e001006c9
|
|||
|
0beaaaf4b1
|
|||
|
84f887df90
|
|||
|
80cf812e54
|
|||
|
19854e59da
|
|||
|
ba47e16b75
|
|||
|
578e80023f
|
|||
|
b7dcee4c06
|
|||
|
e44ec59b6e
|
|||
|
45379e6df1
|
|||
|
308f1f6459
|
|||
| 424ff116d1 | |||
|
|
73f677d319 | ||
|
4770c21499
|
|||
|
720bef90c7
|
|||
|
1c98a231fd
|
|||
|
f6a1be5e80
|
|||
|
dea21b8515
|
|||
|
|
c14619e3e3 | ||
|
bc20f85cbf
|
|||
|
8ec7acd57e
|
|||
|
59e76de4cc
|
|||
|
cc30e6abc1
|
|||
|
6dffe70e9b
|
|||
|
c054d16f08
|
|||
|
1dbd9a5697
|
|||
|
65d6656f47
|
|||
|
a3cb84fa06
|
|||
|
5fcc86d65a
|
|||
|
253872eb57
|
|||
|
609a7ede6c
|
|||
|
49b9bd7782
|
|||
|
c83b90f4f8
|
|||
|
d48a6d9620
|
|||
|
ef80e7a7c4
|
|||
|
5967cd827f
|
|||
|
52558a7167
|
|||
|
24194d7e98
|
|||
|
273ecfbbbf
|
|||
|
c65cb04da9
|
|||
|
919d191e61
|
|||
|
5374a62e96
|
|||
|
2e0dfe8700
|
|||
|
2f30be1490
|
|||
|
79aa2bbaa5
|
|||
|
c92a86015c
|
|||
|
432845195d
|
|||
|
50a1bf6b3f
|
|||
|
8c39fdf190
|
|||
|
9e352e5058
|
10
.deepsource.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
version = 1
|
||||
|
||||
[[analyzers]]
|
||||
name = "python"
|
||||
|
||||
[analyzers.meta]
|
||||
runtime_version = "3.x.x"
|
||||
|
||||
[[analyzers]]
|
||||
name = "docker"
|
||||
@@ -3,24 +3,36 @@ 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__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.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/
|
||||
@@ -47,9 +59,19 @@ Dockerfile*
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Local storage and runtime data
|
||||
storage/
|
||||
testing/
|
||||
telemetry_test_lxmf/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.temp
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
29
.github/workflows/bearer.yml
vendored
@@ -1,29 +0,0 @@
|
||||
name: Bearer Master
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
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:
|
||||
format: sarif
|
||||
output: results.sarif
|
||||
|
||||
- name: Upload SARIF file
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@2827891b2e5e0510dceab8c3619f4fe255451277 # v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
category: bearer-security-scan
|
||||
358
.github/workflows/build.yml
vendored
@@ -4,88 +4,37 @@ 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_windows:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
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.13"
|
||||
|
||||
- name: Install Python Deps
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Install NodeJS Deps
|
||||
run: npm install
|
||||
|
||||
- name: Build Electron App
|
||||
run: npm run dist
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||
with:
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
replacesArtifacts: true
|
||||
omitDraftDuringUpdate: true
|
||||
omitNameDuringUpdate: true
|
||||
artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
|
||||
|
||||
build_mac:
|
||||
runs-on: macos-13
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||
|
||||
- name: Install NodeJS
|
||||
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Python Deps
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Install NodeJS Deps
|
||||
run: npm install
|
||||
|
||||
- name: Build Electron App
|
||||
run: npm run dist
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||
with:
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
replacesArtifacts: true
|
||||
omitDraftDuringUpdate: true
|
||||
omitNameDuringUpdate: true
|
||||
artifacts: "dist/*-mac.dmg"
|
||||
|
||||
build_linux:
|
||||
build_frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||
@@ -98,30 +47,251 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Python Deps
|
||||
run: pip install -r requirements.txt
|
||||
- name: Sync versions
|
||||
run: python scripts/sync_version.py
|
||||
|
||||
- name: Install NodeJS Deps
|
||||
run: npm install
|
||||
|
||||
- name: Build Electron App
|
||||
run: npm run dist
|
||||
- name: Build Frontend
|
||||
run: npm 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 package-lock.json ]; then rm package-lock.json; 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 NodeJS Deps
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
|
||||
run: npm 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: npm 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
|
||||
id: create_release
|
||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||
with:
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
replacesArtifacts: true
|
||||
omitDraftDuringUpdate: true
|
||||
omitNameDuringUpdate: true
|
||||
artifacts: "dist/*-linux.AppImage,dist/*-linux.deb"
|
||||
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
|
||||
@@ -152,9 +322,9 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: >-
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:latest,
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:${{ github.ref_name }}
|
||||
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 MeshChat,
|
||||
org.opencontainers.image.description=Docker image for Reticulum MeshChat,
|
||||
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchat/
|
||||
org.opencontainers.image.title=Reticulum MeshChatX,
|
||||
org.opencontainers.image.description=Docker image for Reticulum MeshChatX,
|
||||
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchatx/
|
||||
|
||||
22
.github/workflows/dependency-review.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: 'Dependency review'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4
|
||||
with:
|
||||
comment-summary-in-pr: always
|
||||
45
.github/workflows/manual-docker-build.yml
vendored
@@ -1,45 +0,0 @@
|
||||
name: Temporary manual trigger for Docker build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_docker:
|
||||
runs-on: ubuntu-latest
|
||||
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-meshchat:latest,
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:${{ github.ref_name }}
|
||||
labels: >-
|
||||
org.opencontainers.image.title=Reticulum MeshChat,
|
||||
org.opencontainers.image.description=Docker image for Reticulum MeshChat,
|
||||
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchat/
|
||||
|
||||
54
.gitignore
vendored
@@ -1,13 +1,57 @@
|
||||
# IDE and editor files
|
||||
.idea
|
||||
node_modules
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# build files
|
||||
# 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/
|
||||
/public/
|
||||
/meshchatx/public/
|
||||
public/
|
||||
/electron/build/exe/
|
||||
python-dist/
|
||||
|
||||
# local storage
|
||||
# Local storage and runtime data
|
||||
storage/
|
||||
testing/
|
||||
telemetry_test_lxmf/
|
||||
|
||||
*.pyc
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
15
Dockerfile
@@ -10,9 +10,8 @@ FROM node:${NODE_VERSION}-alpine@${NODE_ALPINE_SHA256} AS build-frontend
|
||||
WORKDIR /src
|
||||
|
||||
# Copy required source files
|
||||
COPY *.json .
|
||||
COPY *.js .
|
||||
COPY src/frontend ./src/frontend
|
||||
COPY package*.json vite.config.js ./
|
||||
COPY meshchatx ./meshchatx
|
||||
|
||||
# Install NodeJS deps, exluding electron
|
||||
RUN npm install --omit=dev && \
|
||||
@@ -34,12 +33,10 @@ RUN apk add --no-cache --virtual .build-deps \
|
||||
apk del .build-deps
|
||||
|
||||
# Copy prebuilt frontend
|
||||
COPY --from=build-frontend /src/public public
|
||||
COPY --from=build-frontend /src/meshchatx/public meshchatx/public
|
||||
|
||||
# Copy other required source files
|
||||
COPY *.py .
|
||||
COPY src/__init__.py ./src/__init__.py
|
||||
COPY src/backend ./src/backend
|
||||
COPY *.json .
|
||||
COPY meshchatx ./meshchatx
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
|
||||
CMD ["python", "meshchat.py", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]
|
||||
CMD ["python", "-m", "meshchatx.meshchat", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]
|
||||
|
||||
88
Makefile
@@ -1,23 +1,87 @@
|
||||
.PHONY: install run clean
|
||||
.PHONY: install run develop clean build build-appimage build-exe dist sync-version wheel node_modules python build-docker run-docker electron-legacy build-appimage-legacy build-exe-legacy
|
||||
|
||||
VENV = venv
|
||||
PYTHON = $(VENV)/bin/python
|
||||
PIP = $(VENV)/bin/pip
|
||||
PYTHON ?= python
|
||||
POETRY = $(PYTHON) -m poetry
|
||||
NPM = npm
|
||||
LEGACY_ELECTRON_VERSION ?= 30.0.8
|
||||
|
||||
install: $(VENV)
|
||||
npm install
|
||||
DOCKER_COMPOSE_CMD ?= docker compose
|
||||
DOCKER_COMPOSE_FILE ?= docker-compose.yml
|
||||
DOCKER_IMAGE ?= reticulum-meshchatx:local
|
||||
DOCKER_BUILDER ?= meshchatx-builder
|
||||
DOCKER_PLATFORMS ?= linux/amd64
|
||||
DOCKER_BUILD_FLAGS ?= --load
|
||||
DOCKER_BUILD_ARGS ?=
|
||||
DOCKER_CONTEXT ?= .
|
||||
DOCKERFILE ?= Dockerfile
|
||||
|
||||
$(VENV):
|
||||
python3 -m venv $(VENV)
|
||||
$(PIP) install --upgrade pip
|
||||
$(PIP) install -r requirements.txt
|
||||
install: sync-version node_modules python
|
||||
|
||||
node_modules:
|
||||
$(NPM) install
|
||||
|
||||
python:
|
||||
$(POETRY) install
|
||||
|
||||
run: install
|
||||
$(PYTHON) meshchat.py
|
||||
$(POETRY) run meshchat
|
||||
|
||||
develop: run
|
||||
|
||||
build: install
|
||||
$(NPM) run build
|
||||
|
||||
wheel: install
|
||||
$(POETRY) build -f wheel
|
||||
$(PYTHON) scripts/move_wheels.py
|
||||
|
||||
build-appimage: build
|
||||
$(NPM) run electron-postinstall
|
||||
$(NPM) run dist -- --linux AppImage
|
||||
|
||||
build-exe: build
|
||||
$(NPM) run electron-postinstall
|
||||
$(NPM) run dist -- --win portable
|
||||
|
||||
dist: build-appimage
|
||||
|
||||
electron-legacy:
|
||||
$(NPM) install --no-save electron@$(LEGACY_ELECTRON_VERSION)
|
||||
|
||||
# Legacy targets intended for manual/local builds; CI uses workflow jobs.
|
||||
build-appimage-legacy: build electron-legacy
|
||||
$(NPM) run electron-postinstall
|
||||
$(NPM) run dist -- --linux AppImage
|
||||
./scripts/rename_legacy_artifacts.sh
|
||||
|
||||
build-exe-legacy: build electron-legacy
|
||||
$(NPM) run electron-postinstall
|
||||
$(NPM) run dist -- --win portable
|
||||
./scripts/rename_legacy_artifacts.sh
|
||||
|
||||
clean:
|
||||
rm -rf $(VENV)
|
||||
rm -rf node_modules
|
||||
rm -rf build
|
||||
rm -rf dist
|
||||
rm -rf python-dist
|
||||
rm -rf meshchatx/public
|
||||
|
||||
sync-version:
|
||||
$(PYTHON) scripts/sync_version.py
|
||||
|
||||
build-docker:
|
||||
@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:
|
||||
MESHCHAT_IMAGE="$(DOCKER_IMAGE)" \
|
||||
$(DOCKER_COMPOSE_CMD) -f $(DOCKER_COMPOSE_FILE) up --remove-orphans --pull never reticulum-meshchatx
|
||||
|
||||
94
README.md
@@ -1,13 +1,32 @@
|
||||
# Reticulum MeshChatX
|
||||
|
||||
Custom fork of [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat)
|
||||
A heavily customized fork of [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat), any meaningful, stable and tested modifications will be submitted as a PR upstream.
|
||||
|
||||
## Features of this fork
|
||||
|
||||
- More stats in about page.
|
||||
- Exe and Appimage builds with Python 3.13 and Node.js 22
|
||||
- Actions are pinned to full-length SHA hashes.
|
||||
- Docker images are smaller and use SHA256 hashes for the images.
|
||||
- [x] Custom UI/UX (actively being improved)
|
||||
- [x] Ability to set inbound and propagation node stamps.
|
||||
- [x] Better config parsing.
|
||||
- [x] Cancel page fetching or file downloads
|
||||
- [x] Block receiving messages from users.
|
||||
- [ ] Spam filter (based on keywords)
|
||||
- [ ] Multi-identity support.
|
||||
- [ ] Multi-language support
|
||||
- [ ] Offline Reticulum documentation tool
|
||||
- [ ] More tools (translate, LoRa calculator, LXMFy bots, etc.)
|
||||
- [x] Codebase reorganization and cleanup.
|
||||
- [ ] Tests and proper CI/CD pipeline.
|
||||
- [ ] RNS hot reload
|
||||
- [ ] Backup/Import identities, messages and interfaces.
|
||||
- [ ] Full LXST support.
|
||||
- [x] Poetry for packaging and dependency management.
|
||||
- [x] More stats on about page.
|
||||
- [x] Actions are pinned to full-length SHA hashes.
|
||||
- [x] Docker images are smaller and use SHA256 hashes for the images.
|
||||
- [x] Electron improvements (ASAR and security).
|
||||
- [x] Latest updates for NPM and Python dependencies (bleeding edge)
|
||||
- [x] Numerous Ruff, Deepsource, CodeQL Advanced and Bearer Linting/SAST fixes.
|
||||
- [x] Some performance improvements.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -16,21 +35,76 @@ Check [releases](https://github.com/Sudo-Ivan/reticulum-meshchatX/releases) for
|
||||
## Building
|
||||
|
||||
```bash
|
||||
make install
|
||||
make install # installs Python deps via Poetry and Node deps via npm
|
||||
make build
|
||||
```
|
||||
|
||||
You can run `make run` or `make develop` (a thin alias) to start the backend + frontend loop locally through `poetry run meshchat`.
|
||||
|
||||
### Python packaging
|
||||
|
||||
The Python build is driven entirely by Poetry now. Run `python3 scripts/sync_version.py` or `make sync-version` before packaging so `pyproject.toml` and `src/version.py` match `package.json`. After that:
|
||||
|
||||
```bash
|
||||
python -m poetry install
|
||||
make wheel # produces a wheel in python-dist/ that bundles the public assets
|
||||
```
|
||||
|
||||
The wheel includes the frontend `public/` assets, `logo/`, and the CLI entry point, and `python-dist/` keeps the artifact separate from the Electron `dist/` output.
|
||||
|
||||
### Building in Docker
|
||||
|
||||
```bash
|
||||
make docker-build
|
||||
make build-docker
|
||||
```
|
||||
|
||||
The build will be in the `dist` directory.
|
||||
`build-docker` creates `reticulum-meshchatx:local` (or `$(DOCKER_IMAGE)` if you override it) via `docker buildx`. Set `DOCKER_PLATFORMS` to `linux/amd64,linux/arm64` when you need multi-arch images, and adjust `DOCKER_BUILD_FLAGS`/`DOCKER_BUILD_ARGS` to control `--load`/`--push`.
|
||||
|
||||
## Development
|
||||
### Running with Docker Compose
|
||||
|
||||
```bash
|
||||
make develop
|
||||
make run-docker
|
||||
```
|
||||
|
||||
`run-docker` feeds the locally-built image into `docker compose -f docker-compose.yml up --remove-orphans --pull never reticulum-meshchatx`. The compose file uses the `MESHCHAT_IMAGE` env var so you can override the target image without editing the YAML (the default still points at `ghcr.io/sudo-ivan/reticulum-meshchatx:latest`). Use `docker compose down` or `Ctrl+C` to stop the container.
|
||||
|
||||
The Electron build artifacts will still live under `dist/` for releases.
|
||||
|
||||
## Python packaging
|
||||
|
||||
The backend uses Poetry with `pyproject.toml` for dependency management and packaging. Before building, run `python3 scripts/sync_version.py` (or `make sync-version`) to ensure the generated `src/version.py` reflects the version from `package.json` that the Electron artifacts use. This keeps the CLI release metadata, wheel packages, and other bundles aligned.
|
||||
|
||||
### Build artifact locations
|
||||
|
||||
Both `poetry build` and `python -m build` generate wheels inside the default `dist/` directory. The `make wheel` shortcut wraps `poetry build -f wheel` and then runs `python scripts/move_wheels.py` to relocate the generated `.whl` files into `python-dist/` (the layout expected by `scripts/test_wheel.sh` and the release automation). Use `make wheel` if you need the artifacts in `python-dist/`; `poetry build` or `python -m build` alone will leave them in `dist/`.
|
||||
|
||||
### Building with Poetry
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
poetry install
|
||||
|
||||
# Build the package (wheels land in dist/)
|
||||
poetry build
|
||||
|
||||
# Install locally for testing (consumes dist/)
|
||||
pip install dist/*.whl
|
||||
```
|
||||
|
||||
### Building with pip (alternative)
|
||||
|
||||
If you prefer pip, you can build/install directly:
|
||||
|
||||
```bash
|
||||
# Build the wheel
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
# Install locally
|
||||
pip install .
|
||||
```
|
||||
|
||||
### cx_Freeze (for AppImage/NSIS)
|
||||
|
||||
The `cx_setup.py` script uses cx_Freeze for creating standalone executables (AppImage for Linux, NSIS for Windows). This is separate from the Poetry/pip packaging workflow.
|
||||
|
||||
|
||||
47
cx_setup.py
Normal file
@@ -0,0 +1,47 @@
|
||||
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"),
|
||||
]
|
||||
|
||||
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": [
|
||||
"RNS",
|
||||
"RNS.Interfaces",
|
||||
"LXMF",
|
||||
],
|
||||
"include_files": include_files,
|
||||
"excludes": [
|
||||
"PIL",
|
||||
],
|
||||
"optimize": 2,
|
||||
"build_exe": "build/exe",
|
||||
"replace_paths": [
|
||||
("*", ""),
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
167
database.py
@@ -1,167 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from peewee import *
|
||||
from playhouse.migrate import migrate as migrate_database, SqliteMigrator
|
||||
|
||||
latest_version = 5 # increment each time new database migrations are added
|
||||
database = DatabaseProxy() # use a proxy object, as we will init real db client inside meshchat.py
|
||||
migrator = SqliteMigrator(database)
|
||||
|
||||
|
||||
# migrates the database
|
||||
def migrate(current_version):
|
||||
|
||||
# migrate to version 2
|
||||
if current_version < 2:
|
||||
migrate_database(
|
||||
migrator.add_column("lxmf_messages", 'delivery_attempts', LxmfMessage.delivery_attempts),
|
||||
migrator.add_column("lxmf_messages", 'next_delivery_attempt_at', LxmfMessage.next_delivery_attempt_at),
|
||||
)
|
||||
|
||||
# migrate to version 3
|
||||
if current_version < 3:
|
||||
migrate_database(
|
||||
migrator.add_column("lxmf_messages", 'rssi', LxmfMessage.rssi),
|
||||
migrator.add_column("lxmf_messages", 'snr', LxmfMessage.snr),
|
||||
migrator.add_column("lxmf_messages", 'quality', LxmfMessage.quality),
|
||||
)
|
||||
|
||||
# migrate to version 4
|
||||
if current_version < 4:
|
||||
migrate_database(
|
||||
migrator.add_column("lxmf_messages", 'method', LxmfMessage.method),
|
||||
)
|
||||
|
||||
# migrate to version 5
|
||||
if current_version < 5:
|
||||
migrate_database(
|
||||
migrator.add_column("announces", 'rssi', Announce.rssi),
|
||||
migrator.add_column("announces", 'snr', Announce.snr),
|
||||
migrator.add_column("announces", 'quality', Announce.quality),
|
||||
)
|
||||
|
||||
return latest_version
|
||||
|
||||
|
||||
class BaseModel(Model):
|
||||
class Meta:
|
||||
database = database
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
|
||||
id = BigAutoField()
|
||||
key = CharField(unique=True)
|
||||
value = TextField()
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "config"
|
||||
|
||||
|
||||
class Announce(BaseModel):
|
||||
|
||||
id = BigAutoField()
|
||||
destination_hash = CharField(unique=True) # unique destination hash that was announced
|
||||
aspect = TextField(index=True) # aspect is not included in announce, but we want to filter saved announces by aspect
|
||||
identity_hash = CharField(index=True) # identity hash that announced the destination
|
||||
identity_public_key = CharField() # base64 encoded public key, incase we want to recreate the identity manually
|
||||
app_data = TextField(null=True) # base64 encoded app data bytes
|
||||
rssi = IntegerField(null=True)
|
||||
snr = FloatField(null=True)
|
||||
quality = FloatField(null=True)
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "announces"
|
||||
|
||||
|
||||
class CustomDestinationDisplayName(BaseModel):
|
||||
|
||||
id = BigAutoField()
|
||||
destination_hash = CharField(unique=True) # unique destination hash
|
||||
display_name = CharField() # custom display name for the destination hash
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "custom_destination_display_names"
|
||||
|
||||
|
||||
class FavouriteDestination(BaseModel):
|
||||
|
||||
id = BigAutoField()
|
||||
destination_hash = CharField(unique=True) # unique destination hash
|
||||
display_name = CharField() # custom display name for the destination hash
|
||||
aspect = CharField() # e.g: nomadnetwork.node
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "favourite_destinations"
|
||||
|
||||
|
||||
class LxmfMessage(BaseModel):
|
||||
|
||||
id = BigAutoField()
|
||||
hash = CharField(unique=True) # unique lxmf message hash
|
||||
source_hash = CharField(index=True)
|
||||
destination_hash = CharField(index=True)
|
||||
state = CharField() # state is converted from internal int to a human friendly string
|
||||
progress = FloatField() # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
|
||||
is_incoming = BooleanField() # if true, we should ignore state, it's set to draft by default on incoming messages
|
||||
method = CharField(null=True) # what method is being used to send the message, e.g: direct, propagated
|
||||
delivery_attempts = IntegerField(default=0) # how many times delivery has been attempted for this message
|
||||
next_delivery_attempt_at = FloatField(null=True) # timestamp of when the message will attempt delivery again
|
||||
title = TextField()
|
||||
content = TextField()
|
||||
fields = TextField() # json string
|
||||
timestamp = FloatField() # timestamp of when the message was originally created (before ever being sent)
|
||||
rssi = IntegerField(null=True)
|
||||
snr = FloatField(null=True)
|
||||
quality = FloatField(null=True)
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "lxmf_messages"
|
||||
|
||||
|
||||
class LxmfConversationReadState(BaseModel):
|
||||
|
||||
id = BigAutoField()
|
||||
destination_hash = CharField(unique=True) # unique destination hash
|
||||
last_read_at = DateTimeField()
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "lxmf_conversation_read_state"
|
||||
|
||||
|
||||
class LxmfUserIcon(BaseModel):
|
||||
|
||||
id = BigAutoField()
|
||||
destination_hash = CharField(unique=True) # unique destination hash
|
||||
icon_name = CharField() # material design icon name for the destination hash
|
||||
foreground_colour = CharField() # hex colour to use for foreground (icon colour)
|
||||
background_colour = CharField() # hex colour to use for background (background colour)
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "lxmf_user_icons"
|
||||
@@ -1,12 +1,12 @@
|
||||
services:
|
||||
reticulum-meshchat:
|
||||
container_name: reticulum-meshchat
|
||||
image: ghcr.io/liamcottle/reticulum-meshchat:latest
|
||||
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:
|
||||
- 0.0.0.0:8000:8000
|
||||
- 127.0.0.1:8000:8000
|
||||
volumes:
|
||||
- meshchat-config:/config
|
||||
# Uncomment if you have a USB device connected, such as an RNode
|
||||
|
||||
10
donate.md
@@ -1,10 +0,0 @@
|
||||
# Donate
|
||||
|
||||
Thank you for considering donating, this helps support my work on this project 😁
|
||||
|
||||
## How can I donate?
|
||||
|
||||
- Bitcoin: bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q
|
||||
- Ethereum: 0xc64CFbA5D0BF7664158c5671F64d446395b3bF3D
|
||||
- Buy me a Coffee: [https://ko-fi.com/liamcottle](https://ko-fi.com/liamcottle)
|
||||
- Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle)
|
||||
@@ -133,6 +133,14 @@ app.whenReady().then(async () => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -179,13 +187,53 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
// find path to python/cxfreeze reticulum meshchat executable
|
||||
const exeName = process.platform === "win32" ? "ReticulumMeshChat.exe" : "ReticulumMeshChat";
|
||||
var exe = path.join(__dirname, `build/exe/${exeName}`);
|
||||
|
||||
// if dist exe doesn't exist, check local build
|
||||
if(!fs.existsSync(exe)){
|
||||
exe = path.join(__dirname, '..', `build/exe/${exeName}`);
|
||||
// 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 {
|
||||
|
||||
|
||||
3
meshchatx/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Reticulum MeshChatX - A mesh network communications app."""
|
||||
|
||||
__version__ = "2.50.0"
|
||||
225
meshchatx/database.py
Normal file
@@ -0,0 +1,225 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from peewee import * # noqa: F403
|
||||
from playhouse.migrate import SqliteMigrator
|
||||
from playhouse.migrate import migrate as migrate_database
|
||||
|
||||
latest_version = 6 # increment each time new database migrations are added
|
||||
database = (
|
||||
DatabaseProxy() # noqa: F405
|
||||
) # use a proxy object, as we will init real db client inside meshchat.py
|
||||
migrator = SqliteMigrator(database)
|
||||
|
||||
|
||||
# migrates the database
|
||||
def migrate(current_version):
|
||||
# migrate to version 2
|
||||
if current_version < 2:
|
||||
migrate_database(
|
||||
migrator.add_column(
|
||||
"lxmf_messages",
|
||||
"delivery_attempts",
|
||||
LxmfMessage.delivery_attempts,
|
||||
),
|
||||
migrator.add_column(
|
||||
"lxmf_messages",
|
||||
"next_delivery_attempt_at",
|
||||
LxmfMessage.next_delivery_attempt_at,
|
||||
),
|
||||
)
|
||||
|
||||
# migrate to version 3
|
||||
if current_version < 3:
|
||||
migrate_database(
|
||||
migrator.add_column("lxmf_messages", "rssi", LxmfMessage.rssi),
|
||||
migrator.add_column("lxmf_messages", "snr", LxmfMessage.snr),
|
||||
migrator.add_column("lxmf_messages", "quality", LxmfMessage.quality),
|
||||
)
|
||||
|
||||
# migrate to version 4
|
||||
if current_version < 4:
|
||||
migrate_database(
|
||||
migrator.add_column("lxmf_messages", "method", LxmfMessage.method),
|
||||
)
|
||||
|
||||
# migrate to version 5
|
||||
if current_version < 5:
|
||||
migrate_database(
|
||||
migrator.add_column("announces", "rssi", Announce.rssi),
|
||||
migrator.add_column("announces", "snr", Announce.snr),
|
||||
migrator.add_column("announces", "quality", Announce.quality),
|
||||
)
|
||||
|
||||
# migrate to version 6
|
||||
if current_version < 6:
|
||||
migrate_database(
|
||||
migrator.add_column("lxmf_messages", "is_spam", LxmfMessage.is_spam),
|
||||
)
|
||||
|
||||
return latest_version
|
||||
|
||||
|
||||
class BaseModel(Model): # noqa: F405
|
||||
class Meta:
|
||||
database = database
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
id = BigAutoField() # noqa: F405
|
||||
key = CharField(unique=True) # noqa: F405
|
||||
value = TextField() # noqa: F405
|
||||
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "config"
|
||||
|
||||
|
||||
class Announce(BaseModel):
|
||||
id = BigAutoField() # noqa: F405
|
||||
destination_hash = CharField( # noqa: F405
|
||||
unique=True,
|
||||
) # unique destination hash that was announced
|
||||
aspect = TextField( # noqa: F405
|
||||
index=True,
|
||||
) # aspect is not included in announce, but we want to filter saved announces by aspect
|
||||
identity_hash = CharField( # noqa: F405
|
||||
index=True,
|
||||
) # identity hash that announced the destination
|
||||
identity_public_key = (
|
||||
CharField() # noqa: F405
|
||||
) # base64 encoded public key, incase we want to recreate the identity manually
|
||||
app_data = TextField(null=True) # noqa: F405 # base64 encoded app data bytes
|
||||
rssi = IntegerField(null=True) # noqa: F405
|
||||
snr = FloatField(null=True) # noqa: F405
|
||||
quality = FloatField(null=True) # noqa: F405
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "announces"
|
||||
|
||||
|
||||
class CustomDestinationDisplayName(BaseModel):
|
||||
id = BigAutoField() # noqa: F405
|
||||
destination_hash = CharField(unique=True) # noqa: F405 # unique destination hash
|
||||
display_name = CharField() # noqa: F405 # custom display name for the destination hash
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "custom_destination_display_names"
|
||||
|
||||
|
||||
class FavouriteDestination(BaseModel):
|
||||
id = BigAutoField() # noqa: F405
|
||||
destination_hash = CharField(unique=True) # noqa: F405 # unique destination hash
|
||||
display_name = CharField() # noqa: F405 # custom display name for the destination hash
|
||||
aspect = CharField() # noqa: F405 # e.g: nomadnetwork.node
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "favourite_destinations"
|
||||
|
||||
|
||||
class LxmfMessage(BaseModel):
|
||||
id = BigAutoField() # noqa: F405
|
||||
hash = CharField(unique=True) # noqa: F405 # unique lxmf message hash
|
||||
source_hash = CharField(index=True) # noqa: F405
|
||||
destination_hash = CharField(index=True) # noqa: F405
|
||||
state = (
|
||||
CharField() # noqa: F405
|
||||
) # state is converted from internal int to a human friendly string
|
||||
progress = FloatField() # noqa: F405 # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
|
||||
is_incoming = BooleanField() # noqa: F405 # if true, we should ignore state, it's set to draft by default on incoming messages
|
||||
method = CharField( # noqa: F405
|
||||
null=True,
|
||||
) # what method is being used to send the message, e.g: direct, propagated
|
||||
delivery_attempts = IntegerField( # noqa: F405
|
||||
default=0,
|
||||
) # how many times delivery has been attempted for this message
|
||||
next_delivery_attempt_at = FloatField( # noqa: F405
|
||||
null=True,
|
||||
) # timestamp of when the message will attempt delivery again
|
||||
title = TextField() # noqa: F405
|
||||
content = TextField() # noqa: F405
|
||||
fields = TextField() # noqa: F405 # json string
|
||||
timestamp = (
|
||||
FloatField() # noqa: F405
|
||||
) # timestamp of when the message was originally created (before ever being sent)
|
||||
rssi = IntegerField(null=True) # noqa: F405
|
||||
snr = FloatField(null=True) # noqa: F405
|
||||
quality = FloatField(null=True) # noqa: F405
|
||||
is_spam = BooleanField(default=False) # noqa: F405 # if true, message is marked as spam
|
||||
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "lxmf_messages"
|
||||
|
||||
|
||||
class LxmfConversationReadState(BaseModel):
|
||||
id = BigAutoField() # noqa: F405
|
||||
destination_hash = CharField(unique=True) # noqa: F405 # unique destination hash
|
||||
last_read_at = DateTimeField() # noqa: F405
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "lxmf_conversation_read_state"
|
||||
|
||||
|
||||
class LxmfUserIcon(BaseModel):
|
||||
id = BigAutoField() # noqa: F405
|
||||
destination_hash = CharField(unique=True) # noqa: F405 # unique destination hash
|
||||
icon_name = CharField() # noqa: F405 # material design icon name for the destination hash
|
||||
foreground_colour = CharField() # noqa: F405 # hex colour to use for foreground (icon colour)
|
||||
background_colour = (
|
||||
CharField() # noqa: F405
|
||||
) # hex colour to use for background (background colour)
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "lxmf_user_icons"
|
||||
|
||||
|
||||
class BlockedDestination(BaseModel):
|
||||
id = BigAutoField() # noqa: F405
|
||||
destination_hash = CharField( # noqa: F405
|
||||
unique=True,
|
||||
index=True,
|
||||
) # unique destination hash that is blocked
|
||||
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "blocked_destinations"
|
||||
|
||||
|
||||
class SpamKeyword(BaseModel):
|
||||
id = BigAutoField() # noqa: F405
|
||||
keyword = CharField( # noqa: F405
|
||||
unique=True,
|
||||
index=True,
|
||||
) # keyword to match against message content
|
||||
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
|
||||
|
||||
# define table name
|
||||
class Meta:
|
||||
table_name = "spam_keywords"
|
||||
@@ -7,7 +7,6 @@ import sys
|
||||
|
||||
# this class forces stream writes to be flushed immediately
|
||||
class ImmediateFlushingStreamWrapper:
|
||||
|
||||
def __init__(self, stream):
|
||||
self.stream = stream
|
||||
|
||||
1
meshchatx/src/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend utilities shared by the Reticulum MeshChatX CLI."""
|
||||
@@ -1,16 +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):
|
||||
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:
|
||||
self.received_announce_callback(
|
||||
self.aspect_filter,
|
||||
destination_hash,
|
||||
announced_identity,
|
||||
app_data,
|
||||
announce_packet_hash,
|
||||
)
|
||||
except Exception:
|
||||
# ignore failure to handle received announce
|
||||
pass
|
||||
@@ -1,9 +1,8 @@
|
||||
import asyncio
|
||||
from typing import Coroutine
|
||||
from collections.abc import Coroutine
|
||||
|
||||
|
||||
class AsyncUtils:
|
||||
|
||||
# remember main loop
|
||||
main_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
@@ -15,7 +14,6 @@ class AsyncUtils:
|
||||
# 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)
|
||||
@@ -1,11 +1,10 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import RNS
|
||||
|
||||
# todo optionally identity self over link
|
||||
# todo allowlist/denylist for incoming calls
|
||||
# TODO optionally identity self over link
|
||||
# TODO allowlist/denylist for incoming calls
|
||||
|
||||
|
||||
class CallFailedException(Exception):
|
||||
@@ -13,7 +12,6 @@ class CallFailedException(Exception):
|
||||
|
||||
|
||||
class AudioCall:
|
||||
|
||||
def __init__(self, link: RNS.Link, is_outbound: bool):
|
||||
self.link = link
|
||||
self.is_outbound = is_outbound
|
||||
@@ -41,21 +39,25 @@ class AudioCall:
|
||||
|
||||
# handle packet received over link
|
||||
def on_packet(self, message, packet):
|
||||
|
||||
# send audio received from call initiator to all audio packet listeners
|
||||
for audio_packet_listener in self.audio_packet_listeners:
|
||||
audio_packet_listener(message)
|
||||
|
||||
# send an audio packet over the link
|
||||
def send_audio_packet(self, data):
|
||||
|
||||
# do nothing if link is not active
|
||||
if self.is_active() is False:
|
||||
return
|
||||
|
||||
# drop audio packet if it is too big to send
|
||||
if len(data) > RNS.Link.MDU:
|
||||
print("[AudioCall] dropping audio packet " + str(len(data)) + " bytes exceeds the link packet MDU of " + str(RNS.Link.MDU) + " bytes")
|
||||
print(
|
||||
"[AudioCall] dropping audio packet "
|
||||
+ str(len(data))
|
||||
+ " bytes exceeds the link packet MDU of "
|
||||
+ str(RNS.Link.MDU)
|
||||
+ " bytes",
|
||||
)
|
||||
return
|
||||
|
||||
# send codec2 audio received from call receiver to call initiator over reticulum link
|
||||
@@ -73,25 +75,26 @@ class AudioCall:
|
||||
def hangup(self):
|
||||
print("[AudioCall] hangup")
|
||||
self.link.teardown()
|
||||
pass
|
||||
|
||||
|
||||
class AudioCallManager:
|
||||
|
||||
def __init__(self, identity: RNS.Identity):
|
||||
|
||||
def __init__(self, identity: RNS.Identity, is_destination_blocked_callback=None):
|
||||
self.identity = identity
|
||||
self.on_incoming_call_callback = None
|
||||
self.on_outgoing_call_callback = None
|
||||
self.is_destination_blocked_callback = is_destination_blocked_callback
|
||||
self.audio_call_receiver = AudioCallReceiver(manager=self)
|
||||
|
||||
# remember audio calls
|
||||
self.audio_calls: List[AudioCall] = []
|
||||
self.audio_calls: list[AudioCall] = []
|
||||
|
||||
# announces the audio call destination
|
||||
def announce(self, app_data=None):
|
||||
self.audio_call_receiver.destination.announce(app_data)
|
||||
print("[AudioCallManager] announced destination: " + RNS.prettyhexrep(self.audio_call_receiver.destination.hash))
|
||||
print(
|
||||
"[AudioCallManager] announced destination: "
|
||||
+ RNS.prettyhexrep(self.audio_call_receiver.destination.hash),
|
||||
)
|
||||
|
||||
# set the callback for incoming calls
|
||||
def register_incoming_call_callback(self, callback):
|
||||
@@ -103,7 +106,6 @@ class AudioCallManager:
|
||||
|
||||
# handle incoming calls from audio call receiver
|
||||
def handle_incoming_call(self, audio_call: AudioCall):
|
||||
|
||||
# remember it
|
||||
self.audio_calls.append(audio_call)
|
||||
|
||||
@@ -113,7 +115,6 @@ class AudioCallManager:
|
||||
|
||||
# handle outgoing calls
|
||||
def handle_outgoing_call(self, audio_call: AudioCall):
|
||||
|
||||
# remember it
|
||||
self.audio_calls.append(audio_call)
|
||||
|
||||
@@ -142,22 +143,26 @@ class AudioCallManager:
|
||||
def hangup_all(self):
|
||||
for audio_call in self.audio_calls:
|
||||
audio_call.hangup()
|
||||
return None
|
||||
|
||||
# attempts to initiate a call to the provided destination and returns the link hash on success
|
||||
async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15) -> AudioCall:
|
||||
|
||||
async def initiate(
|
||||
self,
|
||||
destination_hash: bytes,
|
||||
timeout_seconds: int = 15,
|
||||
) -> AudioCall:
|
||||
# determine when to timeout
|
||||
timeout_after_seconds = time.time() + timeout_seconds
|
||||
|
||||
# check if we have a path to the destination
|
||||
if not RNS.Transport.has_path(destination_hash):
|
||||
|
||||
# we don't have a path, so we need to request it
|
||||
RNS.Transport.request_path(destination_hash)
|
||||
|
||||
# wait until we have a path, or give up after the configured timeout
|
||||
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after_seconds:
|
||||
while (
|
||||
not RNS.Transport.has_path(destination_hash)
|
||||
and time.time() < timeout_after_seconds
|
||||
):
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# if we still don't have a path, we can't establish a link, so bail out
|
||||
@@ -171,14 +176,16 @@ class AudioCallManager:
|
||||
RNS.Destination.OUT,
|
||||
RNS.Destination.SINGLE,
|
||||
"call",
|
||||
"audio"
|
||||
"audio",
|
||||
)
|
||||
|
||||
# create link
|
||||
link = RNS.Link(server_destination)
|
||||
|
||||
# wait until we have established a link, or give up after the configured timeout
|
||||
while link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds:
|
||||
while (
|
||||
link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds
|
||||
):
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# if we still haven't established a link, bail out
|
||||
@@ -191,16 +198,14 @@ class AudioCallManager:
|
||||
# handle new outgoing call
|
||||
self.handle_outgoing_call(audio_call)
|
||||
|
||||
# todo: this can be optional, it's only being sent by default for ui, can be removed
|
||||
# TODO: this can be optional, it's only being sent by default for ui, can be removed
|
||||
link.identify(self.identity)
|
||||
|
||||
return audio_call
|
||||
|
||||
|
||||
class AudioCallReceiver:
|
||||
|
||||
def __init__(self, manager: AudioCallManager):
|
||||
|
||||
self.manager = manager
|
||||
|
||||
# create destination for receiving audio calls
|
||||
@@ -224,8 +229,24 @@ class AudioCallReceiver:
|
||||
|
||||
# client connected to us, set up an audio call instance
|
||||
def client_connected(self, link: RNS.Link):
|
||||
# check if source is blocked
|
||||
if self.manager.is_destination_blocked_callback is not None:
|
||||
try:
|
||||
# try to get remote identity hash
|
||||
remote_identity = link.get_remote_identity()
|
||||
if remote_identity is not None:
|
||||
source_hash = remote_identity.hash.hex()
|
||||
if self.manager.is_destination_blocked_callback(source_hash):
|
||||
print(
|
||||
f"Rejecting audio call from blocked source: {source_hash}",
|
||||
)
|
||||
link.teardown()
|
||||
return
|
||||
except Exception:
|
||||
# if we can't get identity yet, we'll check later
|
||||
pass
|
||||
|
||||
# todo: this can be optional, it's only being sent by default for ui, can be removed
|
||||
# TODO: this can be optional, it's only being sent by default for ui, can be removed
|
||||
link.identify(self.manager.identity)
|
||||
|
||||
# create audio call
|
||||
@@ -1,10 +1,8 @@
|
||||
class ColourUtils:
|
||||
|
||||
@staticmethod
|
||||
def hex_colour_to_byte_array(hex_colour):
|
||||
|
||||
# remove leading "#"
|
||||
hex_colour = hex_colour.lstrip('#')
|
||||
hex_colour = hex_colour.lstrip("#")
|
||||
|
||||
# convert the remaining hex string to bytes
|
||||
return bytes.fromhex(hex_colour)
|
||||
91
meshchatx/src/backend/interface_config_parser.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import RNS.vendor.configobj
|
||||
|
||||
|
||||
class InterfaceConfigParser:
|
||||
@staticmethod
|
||||
def parse(text):
|
||||
# get lines from provided text
|
||||
lines = text.splitlines()
|
||||
stripped_lines = [line.strip() for line in lines]
|
||||
|
||||
# ensure [interfaces] section exists
|
||||
if "[interfaces]" not in stripped_lines:
|
||||
lines.insert(0, "[interfaces]")
|
||||
stripped_lines.insert(0, "[interfaces]")
|
||||
|
||||
try:
|
||||
# parse lines as rns config object
|
||||
config = RNS.vendor.configobj.ConfigObj(lines)
|
||||
except Exception as e:
|
||||
print(f"Failed to parse interface config with ConfigObj: {e}")
|
||||
return InterfaceConfigParser._parse_best_effort(lines)
|
||||
|
||||
# get interfaces from config
|
||||
config_interfaces = config.get("interfaces", {})
|
||||
if config_interfaces is None:
|
||||
return []
|
||||
|
||||
# process interfaces
|
||||
interfaces = []
|
||||
for interface_name in config_interfaces:
|
||||
# ensure interface has a name
|
||||
interface_config = config_interfaces[interface_name]
|
||||
interface_config["name"] = interface_name
|
||||
interfaces.append(interface_config)
|
||||
|
||||
return interfaces
|
||||
|
||||
@staticmethod
|
||||
def _parse_best_effort(lines):
|
||||
interfaces = []
|
||||
current_interface_name = None
|
||||
current_interface = {}
|
||||
current_sub_name = None
|
||||
current_sub = None
|
||||
|
||||
def commit_sub():
|
||||
nonlocal current_sub_name, current_sub
|
||||
if current_sub_name and current_sub is not None:
|
||||
current_interface[current_sub_name] = current_sub
|
||||
current_sub_name = None
|
||||
current_sub = None
|
||||
|
||||
def commit_interface():
|
||||
nonlocal current_interface_name, current_interface
|
||||
if current_interface_name:
|
||||
# shallow copy to avoid future mutation
|
||||
interfaces.append(dict(current_interface))
|
||||
current_interface_name = None
|
||||
current_interface = {}
|
||||
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if line == "" or line.startswith("#"):
|
||||
continue
|
||||
|
||||
if line.lower() == "[interfaces]":
|
||||
continue
|
||||
|
||||
if line.startswith("[[[") and line.endswith("]]]"):
|
||||
commit_sub()
|
||||
current_sub_name = line[3:-3].strip()
|
||||
current_sub = {}
|
||||
continue
|
||||
|
||||
if line.startswith("[[") and line.endswith("]]"):
|
||||
commit_sub()
|
||||
commit_interface()
|
||||
current_interface_name = line[2:-2].strip()
|
||||
current_interface = {"name": current_interface_name}
|
||||
continue
|
||||
|
||||
if "=" in line and current_interface_name is not None:
|
||||
key, value = line.split("=", 1)
|
||||
target = current_sub if current_sub is not None else current_interface
|
||||
target[key.strip()] = value.strip()
|
||||
|
||||
# commit any pending sections
|
||||
commit_sub()
|
||||
commit_interface()
|
||||
|
||||
return interfaces
|
||||
@@ -1,8 +1,6 @@
|
||||
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 != "":
|
||||
@@ -10,5 +8,4 @@ class InterfaceEditor:
|
||||
return
|
||||
|
||||
# otherwise remove existing value
|
||||
if key in interface_details:
|
||||
del interface_details[key]
|
||||
interface_details.pop(key, None)
|
||||
@@ -8,7 +8,6 @@ from websockets.sync.connection import Connection
|
||||
|
||||
|
||||
class WebsocketClientInterface(Interface):
|
||||
|
||||
# TODO: required?
|
||||
DEFAULT_IFAC_SIZE = 16
|
||||
|
||||
@@ -18,7 +17,6 @@ class WebsocketClientInterface(Interface):
|
||||
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
|
||||
|
||||
def __init__(self, owner, configuration, websocket: Connection = None):
|
||||
|
||||
super().__init__()
|
||||
|
||||
self.owner = owner
|
||||
@@ -26,8 +24,8 @@ class WebsocketClientInterface(Interface):
|
||||
|
||||
self.IN = True
|
||||
self.OUT = False
|
||||
self.HW_MTU = 262144 # 256KiB
|
||||
self.bitrate = 1_000_000_000 # 1Gbps
|
||||
self.HW_MTU = 262144 # 256KiB
|
||||
self.bitrate = 1_000_000_000 # 1Gbps
|
||||
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||
|
||||
# parse config
|
||||
@@ -48,7 +46,6 @@ class WebsocketClientInterface(Interface):
|
||||
|
||||
# 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
|
||||
@@ -65,7 +62,6 @@ class WebsocketClientInterface(Interface):
|
||||
|
||||
# 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
|
||||
@@ -74,8 +70,11 @@ class WebsocketClientInterface(Interface):
|
||||
try:
|
||||
self.websocket.send(data)
|
||||
except Exception as e:
|
||||
RNS.log(f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR)
|
||||
RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR)
|
||||
RNS.log(
|
||||
f"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
|
||||
@@ -87,27 +86,29 @@ class WebsocketClientInterface(Interface):
|
||||
|
||||
# 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 {str(self)}...", RNS.LOG_DEBUG)
|
||||
self.websocket = connect(f"{self.target_url}", max_size=None, compression=None)
|
||||
RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG)
|
||||
RNS.log(f"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 {str(self)}...", RNS.LOG_DEBUG)
|
||||
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:
|
||||
@@ -119,7 +120,6 @@ class WebsocketClientInterface(Interface):
|
||||
self.online = False
|
||||
|
||||
def detach(self):
|
||||
|
||||
# mark as offline
|
||||
self.online = False
|
||||
|
||||
@@ -130,5 +130,6 @@ class WebsocketClientInterface(Interface):
|
||||
# mark as detached
|
||||
self.detached = True
|
||||
|
||||
|
||||
# set interface class RNS should use when importing this external interface
|
||||
interface_class = WebsocketClientInterface
|
||||
@@ -3,33 +3,30 @@ import time
|
||||
|
||||
import RNS
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from websockets.sync.server import Server
|
||||
from websockets.sync.server import serve
|
||||
from websockets.sync.server import ServerConnection
|
||||
|
||||
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}]"
|
||||
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.HW_MTU = 262144 # 256KiB
|
||||
self.bitrate = 1_000_000_000 # 1Gbps
|
||||
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||
|
||||
self.server: Server | None = None
|
||||
@@ -61,12 +58,12 @@ class WebsocketServerInterface(Interface):
|
||||
def clients(self):
|
||||
return len(self.spawned_interfaces)
|
||||
|
||||
# todo docs
|
||||
# TODO docs
|
||||
def received_announce(self, from_spawned=False):
|
||||
if from_spawned:
|
||||
self.ia_freq_deque.append(time.time())
|
||||
|
||||
# todo docs
|
||||
# TODO docs
|
||||
def sent_announce(self, from_spawned=False):
|
||||
if from_spawned:
|
||||
self.oa_freq_deque.append(time.time())
|
||||
@@ -80,17 +77,19 @@ class WebsocketServerInterface(Interface):
|
||||
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)
|
||||
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
|
||||
@@ -101,16 +100,19 @@ class WebsocketServerInterface(Interface):
|
||||
spawned_interface.parent_interface = self
|
||||
spawned_interface.online = True
|
||||
|
||||
# todo implement?
|
||||
# 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?
|
||||
# TODO ifac?
|
||||
# TODO announce rates?
|
||||
|
||||
# activate child interface
|
||||
RNS.log(f"Spawned new WebsocketClientInterface: {spawned_interface}", RNS.LOG_VERBOSE)
|
||||
RNS.log(
|
||||
f"Spawned new WebsocketClientInterface: {spawned_interface}",
|
||||
RNS.LOG_VERBOSE,
|
||||
)
|
||||
RNS.Transport.interfaces.append(spawned_interface)
|
||||
|
||||
# associate child interface with this interface
|
||||
@@ -126,8 +128,13 @@ class WebsocketServerInterface(Interface):
|
||||
|
||||
# run websocket server
|
||||
try:
|
||||
RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG)
|
||||
with serve(on_websocket_client_connected, self.listen_ip, self.listen_port, compression=None) as server:
|
||||
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()
|
||||
@@ -136,12 +143,11 @@ class WebsocketServerInterface(Interface):
|
||||
|
||||
# websocket server is no longer running, let's restart it
|
||||
self.online = False
|
||||
RNS.log(f"Websocket server stopped for {str(self)}...", RNS.LOG_DEBUG)
|
||||
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
|
||||
|
||||
@@ -152,5 +158,6 @@ class WebsocketServerInterface(Interface):
|
||||
# mark as detached
|
||||
self.detached = True
|
||||
|
||||
|
||||
# set interface class RNS should use when importing this external interface
|
||||
interface_class = WebsocketServerInterface
|
||||
1
meshchatx/src/backend/interfaces/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared transport interfaces for MeshChatX."""
|
||||
@@ -1,9 +1,5 @@
|
||||
from typing import List
|
||||
|
||||
|
||||
# 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
|
||||
@@ -11,7 +7,6 @@ class LxmfAudioField:
|
||||
|
||||
# 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
|
||||
@@ -19,7 +14,6 @@ class LxmfImageField:
|
||||
|
||||
# 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
|
||||
@@ -27,7 +21,5 @@ class LxmfFileAttachment:
|
||||
|
||||
# helper class for passing around an lxmf file attachments field
|
||||
class LxmfFileAttachmentsField:
|
||||
|
||||
def __init__(self, file_attachments: List[LxmfFileAttachment]):
|
||||
def __init__(self, file_attachments: list[LxmfFileAttachment]):
|
||||
self.file_attachments = file_attachments
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
|
||||
<title>Phone | Reticulum MeshChat</title>
|
||||
|
||||
<!-- codec2 -->
|
||||
<script src="assets/js/codec2-emscripten/c2enc.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/c2dec.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/sox.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/codec2-lib.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
16
meshchatx/src/frontend/call.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios';
|
||||
import { createApp } from 'vue';
|
||||
import "./style.css";
|
||||
import CallPage from "./components/call/CallPage.vue";
|
||||
import { ensureCodec2ScriptsLoaded } from "./js/Codec2Loader";
|
||||
|
||||
// provide axios globally
|
||||
window.axios = axios;
|
||||
|
||||
async function bootstrap() {
|
||||
await ensureCodec2ScriptsLoaded();
|
||||
createApp(CallPage)
|
||||
.mount('#app');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -1,54 +1,86 @@
|
||||
<template>
|
||||
<div :class="{'dark': config?.theme === 'dark'}" class="h-screen w-full flex flex-col">
|
||||
<div :class="{'dark': config?.theme === 'dark'}" class="h-screen w-full flex flex-col bg-slate-50 dark:bg-zinc-950 transition-colors">
|
||||
|
||||
<!-- header -->
|
||||
<div class="flex bg-white dark:bg-zinc-950 p-2 border-gray-300 dark:border-zinc-900 border-b min-h-16">
|
||||
<div class="flex w-full">
|
||||
<div class="hidden sm:flex my-auto w-12 h-12 mr-2">
|
||||
<img class="w-12 h-12" src="/assets/images/logo-chat-bubble.png" />
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<div @click="onAppNameClick" class="font-bold cursor-pointer text-gray-900 dark:text-zinc-100">Reticulum MeshChat</div>
|
||||
<div class="text-sm text-gray-700 dark:text-white">
|
||||
Developed by
|
||||
<a target="_blank" href="https://liamcottle.com" class="text-blue-500 dark:text-blue-400">Liam Cottle</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
|
||||
<button @click="syncPropagationNode" type="button" class="rounded-full">
|
||||
<span class="flex text-gray-700 dark:text-white bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-600 px-2 py-1 rounded-full">
|
||||
<span :class="{ 'animate-spin': isSyncingPropagationNode }">
|
||||
<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="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>
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm">Sync Messages</span>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="composeNewMessage" type="button" class="rounded-full">
|
||||
<span class="flex text-gray-700 dark:text-white bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-600 px-2 py-1 rounded-full">
|
||||
<span>
|
||||
<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.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm">Compose</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- middle -->
|
||||
<div ref="middle" class="flex h-full w-full overflow-auto">
|
||||
<template v-else>
|
||||
|
||||
<!-- sidebar -->
|
||||
<div class="bg-white flex w-72 min-w-72 flex-col dark:bg-zinc-950">
|
||||
<div class="flex grow flex-col overflow-y-auto border-r border-gray-200 bg-white dark:border-zinc-900 dark:bg-zinc-950">
|
||||
<!-- header -->
|
||||
<div class="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">
|
||||
<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 @click="onAppNameClick" class="font-semibold cursor-pointer text-gray-900 dark:text-zinc-100 tracking-tight text-lg">Reticulum MeshChatX</div>
|
||||
<div class="text-sm text-gray-600 dark:text-zinc-300">
|
||||
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 @click="syncPropagationNode" type="button" class="rounded-full">
|
||||
<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 }">
|
||||
<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="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>
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-medium">Sync Messages</span>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="composeNewMessage" type="button" class="rounded-full">
|
||||
<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>
|
||||
<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.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-semibold">Compose</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- navigation -->
|
||||
<div class="flex-1">
|
||||
<ul class="py-2 pr-2 space-y-1">
|
||||
<!-- onboarding / guidance -->
|
||||
<div v-if="hasGuidanceMessages" class="border-b border-amber-200/60 bg-amber-50/70 text-amber-900 dark:bg-amber-950/30 dark:border-amber-800/40 dark:text-amber-100 transition">
|
||||
<div class="max-w-5xl mx-auto px-4 py-4 space-y-3">
|
||||
<div
|
||||
v-for="message in guidanceMessages"
|
||||
:key="message.id"
|
||||
class="flex flex-col gap-2 rounded-2xl border p-4 text-sm sm:flex-row sm:items-center shadow-sm"
|
||||
:class="guidanceCardClass(message)"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<div class="font-semibold">{{ message.title }}</div>
|
||||
<div class="text-xs sm:text-sm text-amber-900/80 dark:text-amber-100/80">{{ message.description }}</div>
|
||||
</div>
|
||||
<div v-if="message.action_route" class="sm:ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
@click="navigateTo(message.action_route)"
|
||||
class="inline-flex items-center rounded-full bg-amber-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-amber-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
|
||||
>
|
||||
{{ message.action_label || 'Open' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- middle -->
|
||||
<div ref="middle" class="flex h-full w-full overflow-hidden bg-slate-50/80 dark:bg-zinc-950 transition-colors">
|
||||
|
||||
<!-- sidebar -->
|
||||
<div class="bg-transparent flex w-72 min-w-72 flex-col">
|
||||
<div class="flex grow flex-col overflow-y-auto border-r border-gray-200/70 bg-white/80 dark:border-zinc-800 dark:bg-zinc-900/70 backdrop-blur">
|
||||
|
||||
<!-- navigation -->
|
||||
<div class="flex-1">
|
||||
<ul class="py-3 pr-2 space-y-1">
|
||||
|
||||
<!-- messages -->
|
||||
<li>
|
||||
@@ -144,8 +176,8 @@
|
||||
<div>
|
||||
|
||||
<!-- my identity -->
|
||||
<div v-if="config" class="bg-white border-t dark:border-zinc-900 dark:bg-zinc-950">
|
||||
<div @click="isShowingMyIdentitySection = !isShowingMyIdentitySection" class="flex text-gray-700 p-2 cursor-pointer">
|
||||
<div v-if="config" class="bg-white/80 border-t dark:border-zinc-800 dark:bg-zinc-900/70 backdrop-blur">
|
||||
<div @click="isShowingMyIdentitySection = !isShowingMyIdentitySection" class="flex text-gray-700 p-3 cursor-pointer">
|
||||
<div class="my-auto mr-2">
|
||||
<RouterLink @click.stop :to="{ name: 'profile.icon' }">
|
||||
<LxmfUserIcon
|
||||
@@ -162,8 +194,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isShowingMyIdentitySection" class="divide-y text-gray-900 border-t border-gray-300 dark:text-zinc-200 dark:border-zinc-900">
|
||||
<div class="p-1">
|
||||
<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"
|
||||
@@ -172,11 +204,11 @@
|
||||
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-1 dark:border-zinc-900">
|
||||
<div class="p-2 dark:border-zinc-900">
|
||||
<div>Identity Hash</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.identity_hash }}</div>
|
||||
</div>
|
||||
<div class="p-1 dark:border-zinc-900">
|
||||
<div class="p-2 dark:border-zinc-900">
|
||||
<div>LXMF Address</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.lxmf_address_hash }}</div>
|
||||
</div>
|
||||
@@ -184,8 +216,8 @@
|
||||
</div>
|
||||
|
||||
<!-- auto announce -->
|
||||
<div v-if="config" class="bg-white border-t dark:bg-zinc-950 dark:border-zinc-900">
|
||||
<div @click="isShowingAnnounceSection = !isShowingAnnounceSection" class="flex text-gray-700 p-2 cursor-pointer dark:text-white">
|
||||
<div v-if="config" class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-800">
|
||||
<div @click="isShowingAnnounceSection = !isShowingAnnounceSection" class="flex text-gray-700 p-3 cursor-pointer dark:text-white">
|
||||
<div class="my-auto mr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -214,8 +246,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isShowingAnnounceSection" class="divide-y text-gray-900 border-t border-gray-300 dark:text-zinc-200 dark:border-zinc-900">
|
||||
<div class="p-1 dark:border-zinc-900">
|
||||
<div v-if="isShowingAnnounceSection" class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-900">
|
||||
<div class="p-2 dark:border-zinc-900">
|
||||
<select
|
||||
v-model="config.auto_announce_interval_seconds"
|
||||
@change="onAnnounceIntervalSecondsChange"
|
||||
@@ -240,8 +272,8 @@
|
||||
</div>
|
||||
|
||||
<!-- audio calls -->
|
||||
<div v-if="config" class="bg-white border-t dark:bg-zinc-950 dark:border-zinc-900">
|
||||
<div @click="isShowingCallsSection = !isShowingCallsSection" class="flex text-gray-700 p-2 cursor-pointer">
|
||||
<div v-if="config" class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-900">
|
||||
<div @click="isShowingCallsSection = !isShowingCallsSection" class="flex text-gray-700 p-3 cursor-pointer">
|
||||
<div class="my-auto mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="dark:text-white w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
|
||||
@@ -249,19 +281,15 @@
|
||||
</div>
|
||||
<div class="my-auto dark:text-white">Calls</div>
|
||||
<div class="ml-auto">
|
||||
<a @click.stop href="../call.html" target="_blank" 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
|
||||
">
|
||||
<span>Open Phone</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Z" clip-rule="evenodd" />
|
||||
<path fill-rule="evenodd" d="M6.194 12.753a.75.75 0 0 0 1.06.053L16.5 4.44v2.81a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.553l-9.056 8.194a.75.75 0 0 0-.053 1.06Z" clip-rule="evenodd" />
|
||||
<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 overflow-hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4 flex-shrink-0">
|
||||
<path fill-rule="evenodd" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isShowingCallsSection" class="divide-y text-gray-900 border-t border-gray-300 dark:border-zinc-900">
|
||||
<div class="p-1 flex dark:border-zinc-900 dark:text-white">
|
||||
<div v-if="isShowingCallsSection" class="divide-y text-gray-900 border-t border-gray-200 dark:border-zinc-900">
|
||||
<div class="p-2 flex dark:border-zinc-900 dark:text-white">
|
||||
<div>
|
||||
<div>Status</div>
|
||||
<div class="text-sm text-gray-700 dark:text-white">
|
||||
@@ -299,9 +327,10 @@ dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outli
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterView/>
|
||||
<RouterView v-if="!isPopoutMode"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -326,6 +355,7 @@ export default {
|
||||
return {
|
||||
|
||||
reloadInterval: null,
|
||||
appInfoInterval: null,
|
||||
|
||||
isShowingMyIdentitySection: true,
|
||||
isShowingAnnounceSection: true,
|
||||
@@ -343,6 +373,7 @@ export default {
|
||||
beforeUnmount() {
|
||||
|
||||
clearInterval(this.reloadInterval);
|
||||
clearInterval(this.appInfoInterval);
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
@@ -354,6 +385,7 @@ export default {
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
|
||||
this.getAppInfo();
|
||||
this.getConfig();
|
||||
this.updateCallsList();
|
||||
this.updatePropagationNodeStatus();
|
||||
|
||||
@@ -362,9 +394,52 @@ export default {
|
||||
this.updateCallsList();
|
||||
this.updatePropagationNodeStatus();
|
||||
}, 3000);
|
||||
this.appInfoInterval = setInterval(() => {
|
||||
this.getAppInfo();
|
||||
}, 15000);
|
||||
|
||||
},
|
||||
computed: {
|
||||
currentPopoutType() {
|
||||
if(this.$route?.meta?.popoutType){
|
||||
return this.$route.meta.popoutType;
|
||||
}
|
||||
return this.$route?.query?.popout ?? this.getHashPopoutValue();
|
||||
},
|
||||
isPopoutMode() {
|
||||
return this.currentPopoutType != null;
|
||||
},
|
||||
hasGuidanceMessages() {
|
||||
return this.guidanceMessages.length > 0;
|
||||
},
|
||||
guidanceMessages() {
|
||||
if (!this.appInfo || !Array.isArray(this.appInfo.user_guidance)) {
|
||||
return [];
|
||||
}
|
||||
return this.appInfo.user_guidance;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
guidanceCardClass(message) {
|
||||
switch(message.severity){
|
||||
case 'warning':
|
||||
return 'border-amber-200 bg-white text-amber-900 dark:bg-transparent dark:border-amber-300/40';
|
||||
case 'info':
|
||||
default:
|
||||
return 'border-amber-100 bg-white text-amber-900 dark:bg-transparent dark:border-amber-200/30';
|
||||
}
|
||||
},
|
||||
navigateTo(routePath) {
|
||||
if (!routePath) {
|
||||
return;
|
||||
}
|
||||
this.$router.push(routePath);
|
||||
},
|
||||
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){
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<button type="button" class="text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 rounded-full">
|
||||
<button type="button" class="text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 rounded-full w-8 h-8 flex items-center justify-center flex-shrink-0">
|
||||
<slot/>
|
||||
</button>
|
||||
</template>
|
||||
298
meshchatx/src/frontend/components/about/AboutPage.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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">About</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">
|
||||
v{{ appInfo.version }} • RNS {{ appInfo.rns_version }} • LXMF {{ appInfo.lxmf_version }} • Python {{ appInfo.python_version }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isElectron" class="flex flex-col sm:flex-row gap-2">
|
||||
<button @click="relaunch" type="button" class="primary-chip px-4 py-2 text-sm justify-center">
|
||||
<MaterialDesignIcon icon-name="restart" class="w-4 h-4"/>
|
||||
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">Config path</div>
|
||||
<div class="monospace-field break-all">{{ appInfo.reticulum_config_path }}</div>
|
||||
<button v-if="isElectron" @click="showReticulumConfigFile" type="button" class="secondary-chip mt-2 text-xs">
|
||||
<MaterialDesignIcon icon-name="folder" class="w-4 h-4"/>
|
||||
Reveal
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Database path</div>
|
||||
<div class="monospace-field break-all">{{ appInfo.database_path }}</div>
|
||||
<button v-if="isElectron" @click="showDatabaseFile" type="button" class="secondary-chip mt-2 text-xs">
|
||||
<MaterialDesignIcon icon-name="database" class="w-4 h-4"/>
|
||||
Reveal
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Database size</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ formatBytes(appInfo.database_file_size) }}</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">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>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="metric-row">
|
||||
<div>
|
||||
<div class="glass-label">Memory (RSS)</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.rss) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">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">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>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="metric-row">
|
||||
<div>
|
||||
<div class="glass-label">Sent</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.network_stats.bytes_sent) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Received</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.network_stats.bytes_recv) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<div>
|
||||
<div class="glass-label">Packets Sent</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_sent) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">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">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>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="metric-grid">
|
||||
<div>
|
||||
<div class="glass-label">Total Paths</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.total_paths) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Announces / sec</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.announces_per_second) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Announces / min</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.announces_per_minute) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Announces / hr</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">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>
|
||||
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">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">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 ? 'Shared Instance' : 'Standalone Instance' }}
|
||||
</span>
|
||||
<span :class="statusPillClass(appInfo.is_transport_enabled)">
|
||||
<MaterialDesignIcon icon-name="transit-connection" class="w-4 h-4"/>
|
||||
{{ appInfo.is_transport_enabled ? 'Transport Enabled' : '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">Identity & Addresses</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="address-card">
|
||||
<div class="glass-label">Identity Hash</div>
|
||||
<div class="monospace-field break-all">{{ config.identity_hash }}</div>
|
||||
<button @click="copyValue(config.identity_hash, 'Identity Hash')" type="button" class="secondary-chip mt-3 text-xs">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="address-card">
|
||||
<div class="glass-label">LXMF Address</div>
|
||||
<div class="monospace-field break-all">{{ config.lxmf_address_hash }}</div>
|
||||
<button @click="copyValue(config.lxmf_address_hash, 'LXMF Address')" type="button" class="secondary-chip mt-3 text-xs">
|
||||
<MaterialDesignIcon icon-name="account-network" class="w-4 h-4"/>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="address-card">
|
||||
<div class="glass-label">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">Audio Call Address</div>
|
||||
<div class="monospace-field break-all">{{ config.audio_call_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 DialogUtils from "../../js/DialogUtils";
|
||||
export default {
|
||||
name: 'AboutPage',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
appInfo: null,
|
||||
config: null,
|
||||
updateInterval: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.getAppInfo();
|
||||
this.getConfig();
|
||||
// Update stats every 5 seconds
|
||||
this.updateInterval = setInterval(() => {
|
||||
this.getAppInfo();
|
||||
}, 5000);
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
}
|
||||
},
|
||||
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 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);
|
||||
DialogUtils.toast?.(`${label} copied`) ?? DialogUtils.alert(`${label} copied to clipboard`);
|
||||
} catch(e) {
|
||||
DialogUtils.alert(`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);
|
||||
},
|
||||
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";
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isElectron() {
|
||||
return ElectronUtils.isElectron();
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -1,61 +1,62 @@
|
||||
<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 class="flex w-full h-full bg-gray-50 dark:bg-zinc-950" :class="{'dark': config?.theme === 'dark'}">
|
||||
<div class="mx-auto my-auto w-full max-w-2xl p-4 sm:p-6">
|
||||
|
||||
<!-- in active call -->
|
||||
<div v-if="isWebsocketConnected" class="w-full">
|
||||
<div class="border rounded-xl bg-white shadow w-full">
|
||||
<div class="flex border-b border-gray-300 text-gray-700 p-2">
|
||||
<div class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-lg w-full overflow-hidden">
|
||||
<div class="flex items-center border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm px-4 py-3">
|
||||
<div class="my-auto mr-2">
|
||||
<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="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto">Active Call</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-zinc-100">Active Call</div>
|
||||
</div>
|
||||
<div class="border-b border-gray-300 text-gray-700 p-2">
|
||||
<div class="border-b border-gray-200 dark:border-zinc-800 p-4 space-y-3">
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">Call Hash</div>
|
||||
<div class="text-xs text-gray-600">{{ audioCall?.hash || "Unknown" }}</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Call Hash</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono">{{ audioCall?.hash || "Unknown" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">Remote Identity Hash</div>
|
||||
<div class="text-xs text-gray-600">{{ audioCall?.remote_identity_hash || "Unknown" }}</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Remote Identity Hash</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono">{{ audioCall?.remote_identity_hash || "Unknown" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">Remote Destination Hash</div>
|
||||
<div class="text-xs text-gray-600">{{ audioCall?.remote_destination_hash || "Unknown" }}</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Remote Destination Hash</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono">{{ audioCall?.remote_destination_hash || "Unknown" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">Path</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Path</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100">
|
||||
<span v-if="audioCall?.path">{{ audioCall.path.hops }} {{ audioCall.path.hops === 1 ? 'hop' : 'hops' }} away via {{ audioCall.path.next_hop_interface }}</span>
|
||||
<span v-else>Unknown</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">TX Bytes</div>
|
||||
<div class="text-xs text-gray-600">{{ formatBytes(txBytes) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">RX Bytes</div>
|
||||
<div class="text-xs text-gray-600">{{ formatBytes(rxBytes) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">Incoming Audio</div>
|
||||
<div class="text-xs text-gray-600">{{ remoteAudioCodec || "Unknown" }}</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">TX Bytes</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100 font-semibold">{{ formatBytes(txBytes) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">RX Bytes</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100 font-semibold">{{ formatBytes(rxBytes) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">Outgoing Audio</div>
|
||||
<select v-model="codecMode" 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">
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Incoming Audio</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100">{{ remoteAudioCodec || "Unknown" }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Outgoing Audio</div>
|
||||
<select v-model="codecMode" class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 block w-full px-3 py-2 shadow-sm transition-all">
|
||||
<option value="MODE_3200">Codec2 3200</option>
|
||||
<option value="MODE_2400">Codec2 2400</option>
|
||||
<option value="MODE_1600">Codec2 1600</option>
|
||||
@@ -69,10 +70,10 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="flex text-gray-900 p-2">
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-gray-50 dark:bg-zinc-900/50">
|
||||
|
||||
<!-- toggle mic -->
|
||||
<button @click="isMicMuted = !isMicMuted" type="button" :class="[ isMicMuted ? 'bg-red-500 hover:bg-red-400 focus-visible:outline-red-500' : 'bg-gray-500 hover:bg-gray-400 focus-visible:outline-gray-500' ]" class="my-auto inline-flex items-center gap-x-1 rounded-full p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<button @click="isMicMuted = !isMicMuted" type="button" :class="[ isMicMuted ? 'bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 focus-visible:outline-red-500' : 'bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 focus-visible:outline-gray-500' ]" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<svg v-if="isMicMuted" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="w-5 h-5">
|
||||
<path d="M213.38,229.92a8,8,0,0,1-11.3-.54l-30.92-34A78.83,78.83,0,0,1,136,207.59V240a8,8,0,0,1-16,0V207.6A80.11,80.11,0,0,1,48,128a8,8,0,0,1,16,0,64.07,64.07,0,0,0,64,64,63.41,63.41,0,0,0,32.21-8.68l-11.1-12.2A48,48,0,0,1,80,128V95.09L42.08,53.38A8,8,0,0,1,53.92,42.62l160,176A8,8,0,0,1,213.38,229.92Zm-24.19-63.13a7.88,7.88,0,0,0,3.51.82,8,8,0,0,0,7.19-4.49A79.16,79.16,0,0,0,208,128a8,8,0,0,0-16,0,63.32,63.32,0,0,1-6.48,28.09A8,8,0,0,0,189.19,166.79Zm-27.33-29.22A8,8,0,0,0,175.74,133a49.49,49.49,0,0,0,.26-5V64A48,48,0,0,0,84,44.87a8,8,0,0,0,1.41,8.57Z"></path>
|
||||
</svg>
|
||||
@@ -82,7 +83,7 @@
|
||||
</button>
|
||||
|
||||
<!-- toggle sound -->
|
||||
<button @click="isSoundMuted = !isSoundMuted" type="button" :class="[ isSoundMuted ? 'bg-red-500 hover:bg-red-400 focus-visible:outline-red-500' : 'bg-gray-500 hover:bg-gray-400 focus-visible:outline-gray-500' ]" class="ml-1 my-auto inline-flex items-center gap-x-1 rounded-full p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<button @click="isSoundMuted = !isSoundMuted" type="button" :class="[ isSoundMuted ? 'bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 focus-visible:outline-red-500' : 'bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 focus-visible:outline-gray-500' ]" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<svg v-if="isSoundMuted" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path d="M10.047 3.062a.75.75 0 0 1 .453.688v12.5a.75.75 0 0 1-1.264.546L5.203 13H2.667a.75.75 0 0 1-.7-.48A6.985 6.985 0 0 1 1.5 10c0-.887.165-1.737.468-2.52a.75.75 0 0 1 .7-.48h2.535l4.033-3.796a.75.75 0 0 1 .811-.142ZM13.78 7.22a.75.75 0 1 0-1.06 1.06L14.44 10l-1.72 1.72a.75.75 0 0 0 1.06 1.06l1.72-1.72 1.72 1.72a.75.75 0 1 0 1.06-1.06L16.56 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L15.5 8.94l-1.72-1.72Z" />
|
||||
</svg>
|
||||
@@ -93,7 +94,7 @@
|
||||
</button>
|
||||
|
||||
<!-- leave call -->
|
||||
<button @click="leaveCall" type="button" class="ml-auto mr-1 my-auto inline-flex items-center gap-x-1 rounded-full bg-blue-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
<button @click="leaveCall" type="button" class="ml-auto inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@@ -101,7 +102,7 @@
|
||||
</button>
|
||||
|
||||
<!-- hangup call -->
|
||||
<button @click="hangupCall(audioCall.hash)" 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">
|
||||
<button @click="hangupCall(audioCall.hash)" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 rotate-[135deg] translate-y-0.5">
|
||||
<path fill-rule="evenodd" d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@@ -116,60 +117,56 @@
|
||||
<div v-else class="w-full space-y-2">
|
||||
|
||||
<!-- dialer -->
|
||||
<div class="border rounded-xl bg-white shadow w-full overflow-hidden dark:border-zinc-900">
|
||||
<div class="flex border-b border-gray-300 text-gray-700 p-2 dark:bg-zinc-800 dark:text-white">
|
||||
<div class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-lg w-full overflow-hidden">
|
||||
<div class="flex items-center border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm px-4 py-3">
|
||||
<div class="my-auto mr-2">
|
||||
<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 dark:text-white">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto">Start a new Call</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-zinc-100">Start a new Call</div>
|
||||
</div>
|
||||
<div class="flex border-b border-gray-300 text-gray-900 p-2 space-x-2 dark:bg-zinc-700 dark:text-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex-1">
|
||||
<input v-model="destinationHash" type="text" placeholder="Enter Destination Hash" 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 dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100">
|
||||
<div class="p-4 space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<input v-model="destinationHash" @keydown.enter.exact.prevent="initiateCall(destinationHash)" type="text" placeholder="Enter Destination Hash" class="flex-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500">
|
||||
<button @click="initiateCall(destinationHash)" :disabled="isInitiatingCall || !destinationHash || destinationHash.trim() === ''" type="button" :class="[ isInitiatingCall || !destinationHash || destinationHash.trim() === '' ? 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 focus-visible:outline-green-500' ]" class="inline-flex items-center gap-x-1.5 rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<svg v-if="isInitiatingCall" 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>
|
||||
<span v-if="isInitiatingCall">Calling...</span>
|
||||
<span v-else>Call</span>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="initiateCall(destinationHash)" :disabled="isInitiatingCall" type="button" :class="[ isInitiatingCall ? 'bg-gray-400 focus-visible:outline-gray-500' : 'bg-green-500 hover:bg-green-400 focus-visible:outline-green-500' ]" class="my-auto inline-flex items-center gap-x-1 rounded-md p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<span v-if="isInitiatingCall">
|
||||
<span>Calling...</span>
|
||||
</span>
|
||||
<span v-else>Initiate Call</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex p-1 dark:bg-zinc-700 dark:border-zinc-600">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-zinc-800 bg-gray-50 dark:bg-zinc-900/50">
|
||||
<div>
|
||||
<div class='dark:text-white'>My Destination Hash</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-100">{{ myAudioCallAddressHash || "Unknown" }}</div>
|
||||
</div>
|
||||
<div class="ml-auto my-auto mr-1">
|
||||
<a @click="announce" href="javascript:void(0)" class="rounded-full">
|
||||
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded-full">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto mx-1 text-sm">Announce</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">My Destination Hash</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono mt-0.5">{{ myAudioCallAddressHash || "Unknown" }}</div>
|
||||
</div>
|
||||
<button @click="announce" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-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-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z" />
|
||||
</svg>
|
||||
<span>Announce</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- active calls -->
|
||||
<div v-if="activeAudioCalls.length > 0" class="border rounded-xl bg-white shadow w-full overflow-hidden dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100">
|
||||
<div class="flex border-b border-gray-300 text-gray-700 p-2 dark:text-zinc-100">
|
||||
<div v-if="activeAudioCalls.length > 0" class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-lg w-full overflow-hidden">
|
||||
<div class="flex items-center border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm px-4 py-3">
|
||||
<div class="my-auto mr-2">
|
||||
<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="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto">Active Calls</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-zinc-100">Active Calls</div>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<div v-for="audioCall in activeAudioCalls" class="flex p-2">
|
||||
<div class="mr-2 my-auto">
|
||||
<div class="bg-gray-100 p-2 rounded-full">
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
<div v-for="audioCall in activeAudioCalls" class="flex items-center p-4 hover:bg-gray-50 dark:hover:bg-zinc-900/50 transition-colors">
|
||||
<div class="mr-3 flex-shrink-0">
|
||||
<div class="bg-gray-100 dark:bg-zinc-800 p-2.5 rounded-xl">
|
||||
<svg v-if="audioCall.is_outbound" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path d="M3.5 2A1.5 1.5 0 0 0 2 3.5V5c0 1.149.15 2.263.43 3.326a13.022 13.022 0 0 0 9.244 9.244c1.063.28 2.177.43 3.326.43h1.5a1.5 1.5 0 0 0 1.5-1.5v-1.148a1.5 1.5 0 0 0-1.175-1.465l-3.223-.716a1.5 1.5 0 0 0-1.767 1.052l-.267.933c-.117.41-.555.643-.95.48a11.542 11.542 0 0 1-6.254-6.254c-.163-.395.07-.833.48-.95l.933-.267a1.5 1.5 0 0 0 1.052-1.767l-.716-3.223A1.5 1.5 0 0 0 4.648 2H3.5ZM16.5 4.56l-3.22 3.22a.75.75 0 1 1-1.06-1.06l3.22-3.22h-2.69a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0V4.56Z" />
|
||||
</svg>
|
||||
@@ -178,24 +175,24 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>{{ audioCall.remote_destination_hash || "Unknown" }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-zinc-100">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-900 dark:text-zinc-100 truncate">{{ audioCall.remote_destination_hash || "Unknown" }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-zinc-400">
|
||||
<span v-if="audioCall.is_outbound">Outgoing Call...</span>
|
||||
<span v-else>Incoming Call...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2 ml-auto my-auto mx-2">
|
||||
<div class="flex items-center gap-2 ml-auto flex-shrink-0">
|
||||
|
||||
<!-- rejoin call -->
|
||||
<button v-if="audioCall.is_active" title="Join Call" @click="joinCall(audioCall.hash)" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full bg-green-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-green-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-500">
|
||||
<button v-if="audioCall.is_active" title="Join Call" @click="joinCall(audioCall.hash)" type="button" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- hangup call -->
|
||||
<button v-if="audioCall.is_active" title="Hangup Call" @click="hangupCall(audioCall.hash)" 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">
|
||||
<button v-if="audioCall.is_active" title="Hangup Call" @click="hangupCall(audioCall.hash)" type="button" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 rotate-[135deg] translate-y-0.5">
|
||||
<path fill-rule="evenodd" d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@@ -207,24 +204,22 @@
|
||||
</div>
|
||||
|
||||
<!-- call history -->
|
||||
<div v-if="inactiveAudioCalls.length > 0" class="border rounded-xl bg-white shadow w-full overflow-hidden dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100">
|
||||
<div class="flex border-b border-gray-300 text-gray-700 p-2">
|
||||
<div v-if="inactiveAudioCalls.length > 0" class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-lg w-full overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm px-4 py-3">
|
||||
<div class="my-auto mr-2">
|
||||
<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="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto">Call History</div>
|
||||
<div class="ml-auto">
|
||||
<button @click="clearCallHistory" 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">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-zinc-100">Call History</div>
|
||||
<button @click="clearCallHistory" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 px-3 py-1.5 text-xs font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<div v-for="audioCall in inactiveAudioCalls" class="group flex p-2">
|
||||
<div class="mr-2 my-auto">
|
||||
<div class="bg-gray-100 p-2 rounded-full">
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
<div v-for="audioCall in inactiveAudioCalls" class="group flex items-center p-4 hover:bg-gray-50 dark:hover:bg-zinc-900/50 transition-colors">
|
||||
<div class="mr-3 flex-shrink-0">
|
||||
<div class="bg-gray-100 dark:bg-zinc-800 p-2.5 rounded-xl">
|
||||
<svg v-if="audioCall.is_outbound" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path d="M3.5 2A1.5 1.5 0 0 0 2 3.5V5c0 1.149.15 2.263.43 3.326a13.022 13.022 0 0 0 9.244 9.244c1.063.28 2.177.43 3.326.43h1.5a1.5 1.5 0 0 0 1.5-1.5v-1.148a1.5 1.5 0 0 0-1.175-1.465l-3.223-.716a1.5 1.5 0 0 0-1.767 1.052l-.267.933c-.117.41-.555.643-.95.48a11.542 11.542 0 0 1-6.254-6.254c-.163-.395.07-.833.48-.95l.933-.267a1.5 1.5 0 0 0 1.052-1.767l-.716-3.223A1.5 1.5 0 0 0 4.648 2H3.5ZM16.5 4.56l-3.22 3.22a.75.75 0 1 1-1.06-1.06l3.22-3.22h-2.69a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0V4.56Z" />
|
||||
</svg>
|
||||
@@ -233,14 +228,14 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Destination: {{ audioCall.remote_destination_hash || "Unknown" }}</div>
|
||||
<div class="text-sm text-gray-500">Call Hash: {{ audioCall.hash }}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-900 dark:text-zinc-100 truncate">{{ audioCall.remote_destination_hash || "Unknown" }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-zinc-400 font-mono">Call Hash: {{ audioCall.hash }}</div>
|
||||
</div>
|
||||
<div class="hidden group-hover:flex space-x-2 ml-auto my-auto mx-2">
|
||||
<div class="hidden group-hover:flex items-center gap-2 ml-auto flex-shrink-0">
|
||||
|
||||
<!-- delete call -->
|
||||
<button @click="deleteCall(audioCall.hash)" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full bg-gray-100 p-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
|
||||
<button @click="deleteCall(audioCall.hash)" type="button" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 px-3 py-2 text-sm font-semibold text-gray-700 dark:text-zinc-300 shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<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>
|
||||
@@ -1,16 +1,17 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto p-2 space-y-2">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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-5xl mx-auto w-full">
|
||||
|
||||
<!-- community interfaces -->
|
||||
<div v-if="!isEditingInterface && config != null && config.show_suggested_community_interfaces" class="bg-white rounded shadow divide-y divide-gray-200 dark:bg-zinc-900">
|
||||
<div class="flex p-2">
|
||||
<div v-if="!isEditingInterface && config != null && config.show_suggested_community_interfaces" class="bg-white/95 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
<div class="flex p-3">
|
||||
<div class="my-auto mr-auto">
|
||||
<div class="font-bold dark:text-white">Community Interfaces</div>
|
||||
<div class="text-sm dark:text-gray-100">These TCP interfaces serve as a quick way to test Reticulum. We suggest running your own as these may not always be available.</div>
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Quick start</div>
|
||||
<div class="font-semibold text-lg text-gray-900 dark:text-white">Community Interfaces</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-200">One-click helpers for public TCP relays. Spin up your own when possible to ensure availability.</div>
|
||||
</div>
|
||||
<div class="my-auto ml-2">
|
||||
<button @click="updateConfig({'show_suggested_community_interfaces': false})" type="button" class="text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||
<button @click="updateConfig({'show_suggested_community_interfaces': false})" type="button" class="text-gray-700 bg-white border border-gray-200 hover:border-red-300 p-2 rounded-full shadow-sm dark:bg-zinc-800 dark:text-white dark:border-zinc-700 dark:hover:border-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<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>
|
||||
@@ -19,31 +20,31 @@
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200 dark:text-white">
|
||||
|
||||
<div class="flex px-2 py-1">
|
||||
<div class="flex px-3 py-2 items-center">
|
||||
<div class="my-auto mr-auto">
|
||||
<div>RNS Testnet Amsterdam</div>
|
||||
<div class="text-xs">amsterdam.connect.reticulum.network:4965</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">RNS Testnet Amsterdam</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">amsterdam.connect.reticulum.network:4965</div>
|
||||
</div>
|
||||
<div class="ml-2 my-auto">
|
||||
<button
|
||||
@click="newInterfaceName='RNS Testnet Amsterdam';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='amsterdam.connect.reticulum.network';newInterfaceTargetPort='4965'"
|
||||
type="button"
|
||||
class="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">
|
||||
class="inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
<span>Use Interface</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex px-2 py-1">
|
||||
<div class="flex px-3 py-2 items-center">
|
||||
<div class="my-auto mr-auto">
|
||||
<div>RNS Testnet BetweenTheBorders</div>
|
||||
<div class="text-xs">reticulum.betweentheborders.com:4242</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">RNS Testnet BetweenTheBorders</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">reticulum.betweentheborders.com:4242</div>
|
||||
</div>
|
||||
<div class="ml-2 my-auto">
|
||||
<button
|
||||
@click="newInterfaceName='RNS Testnet BetweenTheBorders';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='reticulum.betweentheborders.com';newInterfaceTargetPort='4242'"
|
||||
type="button"
|
||||
class="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">
|
||||
class="inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
<span>Use Interface</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -53,44 +54,64 @@
|
||||
</div>
|
||||
|
||||
<!-- add interface form -->
|
||||
<div class="bg-white rounded shadow divide-y divide-gray-300 dark:divide-zinc-700 dark:bg-zinc-900">
|
||||
<div class="p-2 font-bold dark:text-white">
|
||||
<span v-if="isEditingInterface">Edit Interface</span>
|
||||
<span v-else>Add Interface</span>
|
||||
<div class="bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl">
|
||||
<div class="flex flex-wrap gap-3 items-center p-3 border-b border-gray-200 dark:border-zinc-800">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ isEditingInterface ? 'Update' : 'Create' }}</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ isEditingInterface ? 'Edit Interface' : 'Add Interface' }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Name your connection and select its transport type.</div>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="loadComports" type="button" class="secondary-chip text-xs">
|
||||
Reload Ports
|
||||
</button>
|
||||
<RouterLink :to="{ name: 'interfaces' }" class="secondary-chip text-xs">
|
||||
View All
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 space-y-3">
|
||||
<div class="p-3 md:p-5 space-y-4">
|
||||
|
||||
<!-- iGeneric interface settings -->
|
||||
<!-- interface name -->
|
||||
<div>
|
||||
<FormLabel class="mb-1">Name</FormLabel>
|
||||
<FormLabel class="glass-label">Name</FormLabel>
|
||||
<input type="text" :disabled="isEditingInterface" placeholder="New Interface Name"
|
||||
v-model="newInterfaceName"
|
||||
class="border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white"
|
||||
:class="[ isEditingInterface ? 'cursor-not-allowed bg-gray-200' : 'bg-gray-50' ]">
|
||||
<FormSubLabel>Interface names must be unique.</FormSubLabel>
|
||||
class="input-field"
|
||||
:class="[ isEditingInterface ? 'cursor-not-allowed bg-gray-200 dark:bg-zinc-800' : '' ]">
|
||||
<FormSubLabel class="text-xs">Interface names must be unique.</FormSubLabel>
|
||||
</div>
|
||||
|
||||
<!-- interface type -->
|
||||
<div class="mb-2">
|
||||
<FormLabel class="mb-1">Type</FormLabel>
|
||||
<select v-model="newInterfaceType" 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">
|
||||
<option disabled selected>--</option>
|
||||
<option value="AutoInterface">Auto Interface</option>
|
||||
<option disabled selected>RNodes</option>
|
||||
<option value="RNodeInterface">RNode Interface</option>
|
||||
<option value="RNodeMultiInterface">RNode Multi Interface</option>
|
||||
<option disabled selected>IP Networks</option>
|
||||
<option value="TCPClientInterface">TCP Client Interface</option>
|
||||
<option value="TCPServerInterface">TCP Server Interface</option>
|
||||
<option value="UDPInterface">UDP Interface</option>
|
||||
<option value="I2PInterface">I2P Interface</option>
|
||||
<option disabled selected>Hardware</option>
|
||||
<option value="SerialInterface">Serial Interface</option>
|
||||
<option value="KISSInterface">KISS Interface</option>
|
||||
<option hidden value="AX25KISSInterface">AX.25 KISS Interface</option>
|
||||
<option disabled selected>Other</option>
|
||||
<option value="PipeInterface">Pipe Interface</option>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Type</FormLabel>
|
||||
<select v-model="newInterfaceType" class="input-field">
|
||||
<option disabled selected>Pick a category…</option>
|
||||
<optgroup label="Automatic">
|
||||
<option value="AutoInterface">Auto Interface</option>
|
||||
</optgroup>
|
||||
<optgroup label="RNodes">
|
||||
<option value="RNodeInterface">RNode Interface</option>
|
||||
<option value="RNodeMultiInterface">RNode Multi Interface</option>
|
||||
</optgroup>
|
||||
<optgroup label="IP Networks">
|
||||
<option value="TCPClientInterface">TCP Client Interface</option>
|
||||
<option value="TCPServerInterface">TCP Server Interface</option>
|
||||
<option value="UDPInterface">UDP Interface</option>
|
||||
<option value="I2PInterface">I2P Interface</option>
|
||||
</optgroup>
|
||||
<optgroup label="Hardware">
|
||||
<option value="SerialInterface">Serial Interface</option>
|
||||
<option value="KISSInterface">KISS Interface</option>
|
||||
<option value="AX25KISSInterface">AX.25 KISS Interface</option>
|
||||
</optgroup>
|
||||
<optgroup label="Pipelines">
|
||||
<option value="PipeInterface">Pipe Interface</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<FormSubLabel>
|
||||
Need help? <a class="text-blue-500 underline" href="https://reticulum.network/manual/interfaces.html" target="_blank">Reticulum Docs: Configuring Interfaces</a>
|
||||
@@ -101,13 +122,13 @@
|
||||
<!-- interface target host -->
|
||||
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
|
||||
<FormLabel class="mb-1">Target Host</FormLabel>
|
||||
<input type="text" placeholder="e.g: example.com" v-model="newInterfaceTargetHost" 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">
|
||||
<input type="text" placeholder="e.g: example.com" v-model="newInterfaceTargetHost" class="input-field">
|
||||
</div>
|
||||
|
||||
<!-- interface target port -->
|
||||
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
|
||||
<FormLabel class="mb-1">Target Port</FormLabel>
|
||||
<input type="text" placeholder="e.g: 1234" v-model="newInterfaceTargetPort" 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">
|
||||
<input type="text" placeholder="e.g: 1234" v-model="newInterfaceTargetPort" class="input-field">
|
||||
</div>
|
||||
|
||||
<!-- TCPServerInterface -->
|
||||
@@ -1317,3 +1338,24 @@ export default {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.glass-card {
|
||||
@apply bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl;
|
||||
}
|
||||
.input-field {
|
||||
@apply bg-gray-50/90 dark:bg-zinc-900/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-2xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-2.5 text-gray-900 dark:text-gray-100 transition;
|
||||
}
|
||||
.glass-label {
|
||||
@apply mb-1 text-sm font-semibold text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
.primary-chip {
|
||||
@apply inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
|
||||
}
|
||||
.secondary-chip {
|
||||
@apply inline-flex items-center gap-x-2 rounded-full border border-gray-300 dark:border-zinc-700 px-3 py-1.5 text-xs font-semibold text-gray-700 dark:text-gray-100 bg-white/80 dark:bg-zinc-900/70 hover:border-blue-400;
|
||||
}
|
||||
.glass-field {
|
||||
@apply space-y-1;
|
||||
}
|
||||
</style>
|
||||
@@ -125,9 +125,10 @@ export default {
|
||||
this.importableInterfaces = [];
|
||||
this.selectedInterfaces = [];
|
||||
},
|
||||
dismiss() {
|
||||
dismiss(result = false) {
|
||||
this.isShowing = false;
|
||||
this.$emit("dismissed");
|
||||
const imported = result === true;
|
||||
this.$emit("dismissed", imported);
|
||||
},
|
||||
clearSelectedFile() {
|
||||
this.selectedFile = null;
|
||||
@@ -221,7 +222,7 @@ export default {
|
||||
});
|
||||
|
||||
// dismiss modal
|
||||
this.dismiss();
|
||||
this.dismiss(true);
|
||||
|
||||
// tell user interfaces were imported
|
||||
DialogUtils.alert("Interfaces imported successfully. MeshChat must be restarted for these changes to take effect.");
|
||||
251
meshchatx/src/frontend/components/interfaces/Interface.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<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) ? 'Enabled' : '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 class="stat-chip" v-if="iface._stats?.bitrate">Bitrate {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</span>
|
||||
<span class="stat-chip">TX {{ formatBytes(iface._stats?.txb ?? 0) }}</span>
|
||||
<span class="stat-chip">RX {{ formatBytes(iface._stats?.rxb ?? 0) }}</span>
|
||||
<span class="stat-chip" v-if="iface.type === 'RNodeInterface' && iface._stats?.noise_floor">Noise {{ iface._stats?.noise_floor }} dBm</span>
|
||||
<span class="stat-chip" v-if="iface._stats?.clients != null">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 @click="onIFACSignatureClick(iface._stats.ifac_signature)" type="button" class="text-blue-500 hover:underline">
|
||||
{{ 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">
|
||||
<button
|
||||
v-if="isInterfaceEnabled(iface)"
|
||||
@click="disableInterface"
|
||||
type="button"
|
||||
class="secondary-chip text-xs"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="power" class="w-4 h-4"/>
|
||||
Disable
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="enableInterface"
|
||||
type="button"
|
||||
class="primary-chip text-xs"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="power" class="w-4 h-4"/>
|
||||
Enable
|
||||
</button>
|
||||
<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 pr-1">
|
||||
<DropDownMenuItem @click="editInterface">
|
||||
<MaterialDesignIcon icon-name="pencil" class="w-5 h-5"/>
|
||||
<span>Edit Interface</span>
|
||||
</DropDownMenuItem>
|
||||
<DropDownMenuItem @click="exportInterface">
|
||||
<MaterialDesignIcon icon-name="export" class="w-5 h-5"/>
|
||||
<span>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">Delete Interface</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
</template>
|
||||
</DropDownMenu>
|
||||
</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">Listen</div>
|
||||
<div class="detail-value">{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">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">Port</div>
|
||||
<div class="detail-value">{{ iface.port }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">Frequency</div>
|
||||
<div class="detail-value">{{ formatFrequency(iface.frequency) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">Bandwidth</div>
|
||||
<div class="detail-value">{{ formatFrequency(iface.bandwidth) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">Spreading Factor</div>
|
||||
<div class="detail-value">{{ iface.spreadingfactor }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">Coding Rate</div>
|
||||
<div class="detail-value">{{ iface.codingrate }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">TX Power</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: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
};
|
||||
},
|
||||
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);
|
||||
},
|
||||
},
|
||||
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";
|
||||
},
|
||||
},
|
||||
}
|
||||
</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;
|
||||
}
|
||||
.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>
|
||||
340
meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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">Restart required</div>
|
||||
<div class="text-sm">Reticulum MeshChat must be restarted for any interface changes to take effect.</div>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="isElectron" @click="relaunch" 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">
|
||||
<MaterialDesignIcon icon-name="restart" class="w-4 h-4"/>
|
||||
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">Manage</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Search, filter and export your Reticulum adapters.</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"/>
|
||||
Add Interface
|
||||
</RouterLink>
|
||||
<button @click="showImportInterfacesModal" type="button" class="secondary-chip text-sm">
|
||||
<MaterialDesignIcon icon-name="import" class="w-4 h-4"/>
|
||||
Import
|
||||
</button>
|
||||
<button @click="exportInterfaces" type="button" class="secondary-chip text-sm">
|
||||
<MaterialDesignIcon icon-name="export" class="w-4 h-4"/>
|
||||
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="Search by name, type, host..."
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button type="button" @click="setStatusFilter('all')" :class="filterChipClass(statusFilter === 'all')">All</button>
|
||||
<button type="button" @click="setStatusFilter('enabled')" :class="filterChipClass(statusFilter === 'enabled')">Enabled</button>
|
||||
<button type="button" @click="setStatusFilter('disabled')" :class="filterChipClass(statusFilter === 'disabled')">Disabled</button>
|
||||
</div>
|
||||
<div class="w-full sm:w-60">
|
||||
<select v-model="typeFilter" class="input-field">
|
||||
<option value="all">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">No interfaces found</div>
|
||||
<div class="text-sm">Adjust your search or add a new interface.</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,
|
||||
};
|
||||
},
|
||||
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(e) {
|
||||
// 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(e) {
|
||||
// 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";
|
||||
},
|
||||
},
|
||||
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();
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +1,16 @@
|
||||
<template>
|
||||
<div class="inline-flex rounded-md shadow-sm">
|
||||
<div class="inline-flex">
|
||||
|
||||
<button v-if="isRecordingAudioAttachment" @click="stopRecordingAudioAttachment" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2.5 py-1.5 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 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
|
||||
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
|
||||
<path d="M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z" />
|
||||
</svg>
|
||||
<button v-if="isRecordingAudioAttachment" @click="stopRecordingAudioAttachment" 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">
|
||||
<MaterialDesignIcon icon-name="microphone" class="w-4 h-4"/>
|
||||
<span class="ml-1">
|
||||
<slot/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button v-else @click="showMenu" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
|
||||
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
|
||||
<path d="M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z" />
|
||||
</svg>
|
||||
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Voice</span>
|
||||
<button v-else @click="showMenu" 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">
|
||||
<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">
|
||||
@@ -27,11 +21,11 @@
|
||||
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-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<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 @click="startRecordingCodec2('1200')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Low Quality - Codec2 (1200)</button>
|
||||
<button @click="startRecordingCodec2('3200')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Medium Quality - Codec2 (3200)</button>
|
||||
<button @click="startRecordingOpus()" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">High Quality - OPUS</button>
|
||||
<button @click="startRecordingCodec2('1200')" 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">Low Quality - Codec2 (1200)</button>
|
||||
<button @click="startRecordingCodec2('3200')" 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">Medium Quality - Codec2 (3200)</button>
|
||||
<button @click="startRecordingOpus()" 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">High Quality - OPUS</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -41,8 +35,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: 'AddAudioButton',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
isRecordingAudioAttachment: Boolean,
|
||||
},
|
||||
@@ -1,11 +1,9 @@
|
||||
<template>
|
||||
<div class="inline-flex rounded-md shadow-sm">
|
||||
<div class="inline-flex">
|
||||
|
||||
<button @click="showMenu" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M1.5 6a2.25 2.25 0 0 1 2.25-2.25h16.5A2.25 2.25 0 0 1 22.5 6v12a2.25 2.25 0 0 1-2.25 2.25H3.75A2.25 2.25 0 0 1 1.5 18V6ZM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0 0 21 18v-1.94l-2.69-2.689a1.5 1.5 0 0 0-2.12 0l-.88.879.97.97a.75.75 0 1 1-1.06 1.06l-5.16-5.159a1.5 1.5 0 0 0-2.12 0L3 16.061Zm10.125-7.81a1.125 1.125 0 1 1 2.25 0 1.125 1.125 0 0 1-2.25 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Image</span>
|
||||
<button @click="showMenu" 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">
|
||||
<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">
|
||||
@@ -16,12 +14,12 @@
|
||||
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-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<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 @click="addImage('low')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Low Quality (320x320)</button>
|
||||
<button @click="addImage('medium')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Medium Quality (640x640)</button>
|
||||
<button @click="addImage('high')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">High Quality (1280x1280)</button>
|
||||
<button @click="addImage('original')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Original Quality</button>
|
||||
<button @click="addImage('low')" 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">Low Quality (320x320)</button>
|
||||
<button @click="addImage('medium')" 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">Medium Quality (640x640)</button>
|
||||
<button @click="addImage('high')" 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">High Quality (1280x1280)</button>
|
||||
<button @click="addImage('original')" 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">Original Quality</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -36,8 +34,12 @@
|
||||
<script>
|
||||
import Compressor from 'compressorjs';
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: 'AddImageButton',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
emits: [
|
||||
"add-image",
|
||||
],
|
||||
@@ -35,6 +35,20 @@
|
||||
<span>Set Custom Display Name</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- block/unblock button -->
|
||||
<div class="border-t">
|
||||
<DropDownMenuItem v-if="!isBlocked" @click="onBlockDestination">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5 text-red-500">
|
||||
<path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3V13.5a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm-1.5 8.25v3a1.5 1.5 0 0 0 3 0v-3a1.5 1.5 0 0 0-3 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<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">
|
||||
@@ -53,6 +67,7 @@
|
||||
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 {
|
||||
@@ -61,6 +76,7 @@ export default {
|
||||
IconButton,
|
||||
DropDownMenuItem,
|
||||
DropDownMenu,
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
peer: Object,
|
||||
@@ -68,8 +84,72 @@ export default {
|
||||
emits: [
|
||||
"conversation-deleted",
|
||||
"set-custom-display-name",
|
||||
"block-status-changed",
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
isBlocked: false,
|
||||
blockedDestinations: [],
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadBlockedDestinations();
|
||||
},
|
||||
watch: {
|
||||
peer: {
|
||||
handler() {
|
||||
this.checkIfBlocked();
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
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
|
||||
@@ -1,33 +1,35 @@
|
||||
<template>
|
||||
|
||||
<!-- peer selected -->
|
||||
<div v-if="selectedPeer" class="flex flex-col h-full bg-white overflow-hidden sm:m-2 sm:border sm:rounded-xl sm:shadow dark:bg-zinc-950 dark:border-zinc-800">
|
||||
<div v-if="selectedPeer" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-3 sm:border sm:rounded-2xl sm:shadow-lg border-gray-200/50 dark:border-zinc-800/50 transition-all">
|
||||
|
||||
<!-- header -->
|
||||
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
|
||||
<div class="flex items-center px-4 py-3 border-b border-gray-200/60 dark:border-zinc-800/60 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm">
|
||||
|
||||
<!-- peer icon -->
|
||||
<div class="my-auto mr-2">
|
||||
<div v-if="selectedPeer.lxmf_user_icon" class="p-2 rounded" :style="{ 'color': selectedPeer.lxmf_user_icon.foreground_colour, 'background-color': selectedPeer.lxmf_user_icon.background_colour }">
|
||||
<div class="flex-shrink-0 mr-3">
|
||||
<div v-if="selectedPeer.lxmf_user_icon" class="p-2 rounded shadow-sm" :style="{ 'color': selectedPeer.lxmf_user_icon.foreground_colour, 'background-color': selectedPeer.lxmf_user_icon.background_colour }">
|
||||
<MaterialDesignIcon :icon-name="selectedPeer.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">
|
||||
<div v-else class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded shadow-sm">
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- peer info -->
|
||||
<div>
|
||||
<div @click="updateCustomDisplayName" class="flex cursor-pointer">
|
||||
<div v-if="selectedPeer.custom_display_name != null" class="my-auto mr-1 dark:text-white" title="Custom Display Name">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div @click="updateCustomDisplayName" class="flex items-center cursor-pointer min-w-0 group">
|
||||
<div v-if="selectedPeer.custom_display_name != null" class="mr-1.5 text-gray-500 dark:text-zinc-400 group-hover:text-gray-700 dark:group-hover:text-zinc-200 transition-colors" title="Custom Display Name">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto font-semibold dark:text-white" :title="selectedPeer.display_name">{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-xs sm:max-w-sm text-base" :title="selectedPeer.custom_display_name ?? selectedPeer.display_name">
|
||||
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm dark:text-zinc-300">
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
|
||||
|
||||
<!-- destination hash -->
|
||||
<div class="inline-block mr-1">
|
||||
@@ -62,18 +64,22 @@
|
||||
</div>
|
||||
|
||||
<!-- dropdown menu -->
|
||||
<div class="ml-auto my-auto mx-2">
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<ConversationDropDownMenu
|
||||
v-if="selectedPeer"
|
||||
:peer="selectedPeer"
|
||||
@conversation-deleted="onConversationDeleted"
|
||||
@set-custom-display-name="updateCustomDisplayName"/>
|
||||
</div>
|
||||
@set-custom-display-name="updateCustomDisplayName"
|
||||
@block-status-changed="loadBlockedDestinations"/>
|
||||
|
||||
<!-- popout button -->
|
||||
<IconButton @click="openConversationPopout" title="Pop out chat" class="text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-200">
|
||||
<MaterialDesignIcon icon-name="open-in-new" class="w-4 h-4"/>
|
||||
</IconButton>
|
||||
|
||||
<!-- close button -->
|
||||
<div class="my-auto mr-2">
|
||||
<IconButton @click="close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<!-- close button -->
|
||||
<IconButton @click="close" class="text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-200">
|
||||
<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>
|
||||
</IconButton>
|
||||
@@ -82,37 +88,59 @@
|
||||
</div>
|
||||
|
||||
<!-- chat items -->
|
||||
<div @scroll="onMessagesScroll" id="messages" class="h-full overflow-y-scroll">
|
||||
<div @scroll="onMessagesScroll" id="messages" class="h-full overflow-y-scroll bg-gray-50/30 dark:bg-zinc-950/50">
|
||||
|
||||
<div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse p-3">
|
||||
<div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse px-4 py-6">
|
||||
|
||||
<div v-for="chatItem of selectedPeerChatItemsReversed" :key="chatItem.lxmf_message.hash" class="flex flex-col max-w-xl mt-3" :class="{ 'ml-auto pl-4 md:pl-16 items-end': chatItem.is_outbound, 'mr-auto pr-4 md:pr-16 items-start': !chatItem.is_outbound }">
|
||||
<div v-for="chatItem of selectedPeerChatItemsReversed" :key="chatItem.lxmf_message.hash" class="flex flex-col max-w-[75%] sm:max-w-[65%] lg:max-w-[55%] mb-4 group" :class="{ 'ml-auto items-end': chatItem.is_outbound, 'mr-auto items-start': !chatItem.is_outbound }">
|
||||
|
||||
<!-- message content -->
|
||||
<div @click="onChatItemClick(chatItem)" class="border border-gray-300 dark:border-zinc-800 rounded-xl shadow overflow-hidden" :class="[ ['cancelled', 'failed'].includes(chatItem.lxmf_message.state) ? 'bg-red-500 text-white' : chatItem.is_outbound ? 'bg-[#3b82f6] text-white' : 'bg-[#efefef]' ]">
|
||||
<div @click="onChatItemClick(chatItem)" class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md" :class="[
|
||||
['cancelled', 'failed'].includes(chatItem.lxmf_message.state)
|
||||
? 'bg-red-500 text-white shadow-sm'
|
||||
: chatItem.lxmf_message.is_spam
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-900 dark:text-yellow-100 border border-yellow-300 dark:border-yellow-700 shadow-sm'
|
||||
: chatItem.is_outbound
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'bg-white dark:bg-zinc-900 text-gray-900 dark:text-zinc-100 border border-gray-200/60 dark:border-zinc-800/60 shadow-sm'
|
||||
]">
|
||||
|
||||
<div class="w-full space-y-0.5 px-2.5 py-1">
|
||||
<div class="w-full space-y-1 px-4 py-2.5">
|
||||
|
||||
<!-- spam badge -->
|
||||
<div v-if="chatItem.lxmf_message.is_spam" class="flex items-center gap-1.5 text-xs font-medium mb-1" :class="chatItem.is_outbound ? 'text-yellow-200' : 'text-yellow-700 dark:text-yellow-300'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
<span>Marked as Spam</span>
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<div v-if="chatItem.lxmf_message.content" style="white-space:pre-wrap;word-break:break-word;font-family:inherit;">{{ chatItem.lxmf_message.content }}</div>
|
||||
<div v-if="chatItem.lxmf_message.content" class="text-sm leading-relaxed whitespace-pre-wrap break-words" style="font-family:inherit;">{{ chatItem.lxmf_message.content }}</div>
|
||||
|
||||
<!-- image field -->
|
||||
<div v-if="chatItem.lxmf_message.fields?.image">
|
||||
<img @click.stop="openImage(`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`)" :src="`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`" class="w-full rounded-md cursor-pointer"/>
|
||||
<div v-if="chatItem.lxmf_message.fields?.image" class="relative group mt-1 -mx-1">
|
||||
<img
|
||||
@click.stop="openImage(`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`)"
|
||||
:src="`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`"
|
||||
class="w-full rounded-lg cursor-pointer transition-transform group-hover:scale-[1.01]"/>
|
||||
<div class="absolute bottom-2 left-2 bg-black/60 backdrop-blur-sm text-white text-xs px-2.5 py-1 rounded-lg flex items-center gap-1.5">
|
||||
<span>{{ (chatItem.lxmf_message.fields.image.image_type ?? 'image').toUpperCase() }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ formatBase64Bytes(chatItem.lxmf_message.fields.image.image_bytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- audio field -->
|
||||
<div v-if="chatItem.lxmf_message.fields?.audio" class="pb-1">
|
||||
|
||||
<!-- audio is loaded -->
|
||||
<audio v-if="lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" controls class="shadow rounded-full" style="height:54px;">
|
||||
<source :src="lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" type="audio/wav"/>
|
||||
</audio>
|
||||
<audio v-if="lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" controls class="w-full rounded-lg shadow-sm" style="height:54px;" :class="chatItem.is_outbound ? 'audio-controls-light' : 'audio-controls-dark'"></audio>
|
||||
|
||||
<!-- audio is not yet loaded -->
|
||||
<!-- min height to make sure audio player doesn't cause height increase after loading -->
|
||||
<div v-else style="min-height:54px;" class="flex">
|
||||
<button @click="downloadFileFromBase64('audio.bin', chatItem.lxmf_message.fields.audio.audio_bytes)" type="button" class="my-auto flex border border-gray-300 dark:border-zinc-800 hover:bg-gray-100 rounded px-2 py-1 text-sm text-gray-700 font-semibold cursor-pointer space-x-2 bg-[#efefef]">
|
||||
<button @click="downloadFileFromBase64('audio.bin', chatItem.lxmf_message.fields.audio.audio_bytes)" type="button" class="my-auto flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors" :class="chatItem.is_outbound ? 'bg-white/20 text-white border-white/20 hover:bg-white/30' : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'">
|
||||
<span class="my-auto">
|
||||
<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="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" />
|
||||
@@ -129,17 +157,30 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs mt-1.5" :class="chatItem.is_outbound ? 'text-white/70' : 'text-gray-500 dark:text-zinc-400'">
|
||||
Audio • {{ formatBase64Bytes(chatItem.lxmf_message.fields.audio.audio_bytes) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- file attachment fields -->
|
||||
<div v-if="chatItem.lxmf_message.fields?.file_attachments" class="space-y-1">
|
||||
<a @click.stop target="_blank" :download="file_attachment.file_name" :href="`data:application/octet-stream;base64,${file_attachment.file_bytes}`" v-for="file_attachment of chatItem.lxmf_message.fields?.file_attachments ?? []" class="flex border border-gray-300 dark:border-zinc-800 hover:bg-gray-100 rounded px-2 py-1 text-sm text-gray-700 font-semibold cursor-pointer space-x-2 bg-[#efefef]">
|
||||
<div v-if="chatItem.lxmf_message.fields?.file_attachments" class="space-y-2 mt-1">
|
||||
<a
|
||||
v-for="file_attachment of chatItem.lxmf_message.fields?.file_attachments ?? []"
|
||||
:key="file_attachment.file_name"
|
||||
@click.stop
|
||||
target="_blank"
|
||||
:download="file_attachment.file_name"
|
||||
:href="`data:application/octet-stream;base64,${file_attachment.file_bytes}`"
|
||||
class="flex items-center gap-3 border rounded-lg px-3 py-2 text-sm font-medium cursor-pointer transition-colors" :class="chatItem.is_outbound ? 'bg-white/20 text-white border-white/20 hover:bg-white/30' : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300 border-gray-200/60 dark:border-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-800'">
|
||||
<div class="my-auto">
|
||||
<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="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto w-full">{{ file_attachment.file_name }}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="truncate">{{ file_attachment.file_name }}</div>
|
||||
<div class="text-xs font-normal mt-0.5" :class="chatItem.is_outbound ? 'text-white/60' : 'text-gray-500 dark:text-zinc-400'">{{ formatBase64Bytes(file_attachment.file_bytes) }}</div>
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<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="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
@@ -151,10 +192,10 @@
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div v-if="chatItem.is_actions_expanded" class="border-t p-1 bg-[#efefef] text-white">
|
||||
<div v-if="chatItem.is_actions_expanded" class="border-t px-4 py-2.5" :class="chatItem.is_outbound ? 'border-white/20 bg-white/10' : 'border-gray-200/60 dark:border-zinc-800/60 bg-gray-50/50 dark:bg-zinc-900/50'">
|
||||
|
||||
<!-- delete message -->
|
||||
<button @click.stop="deleteChatItem(chatItem)" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2 py-1 text-xs 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">
|
||||
<button @click.stop="deleteChatItem(chatItem)" type="button" class="inline-flex items-center gap-x-1.5 rounded-lg bg-red-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-600 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -163,12 +204,12 @@
|
||||
</div>
|
||||
|
||||
<!-- message state -->
|
||||
<div v-if="chatItem.is_outbound" class="flex text-right" :class="[ ['cancelled', 'failed'].includes(chatItem.lxmf_message.state) ? 'text-red-500' : 'text-gray-500' ]">
|
||||
<div class="flex ml-auto space-x-1">
|
||||
<div v-if="chatItem.is_outbound" class="flex text-right mt-1.5 px-1" :class="[ ['cancelled', 'failed'].includes(chatItem.lxmf_message.state) ? 'text-red-500 dark:text-red-400' : 'text-gray-400 dark:text-zinc-500' ]">
|
||||
<div class="flex ml-auto items-center space-x-1.5 text-xs">
|
||||
|
||||
<!-- state label -->
|
||||
<div class="my-auto">
|
||||
<span @click="showSentMessageInfo(chatItem.lxmf_message)" class="space-x-1 cursor-pointer">
|
||||
<span @click="toggleSentMessageInfo(chatItem.lxmf_message.hash)" class="space-x-1 cursor-pointer hover:underline">
|
||||
<span>{{ chatItem.lxmf_message.state }}</span>
|
||||
<span v-if="chatItem.lxmf_message.state === 'outbound' && chatItem.lxmf_message.delivery_attempts >= 1">(attempt {{ chatItem.lxmf_message.delivery_attempts + 1 }})</span>
|
||||
<span v-if="chatItem.lxmf_message.state === 'sent' && chatItem.lxmf_message.method === 'opportunistic' && chatItem.lxmf_message.delivery_attempts >= 1">(attempt {{ chatItem.lxmf_message.delivery_attempts }})</span>
|
||||
@@ -211,18 +252,23 @@
|
||||
</div>
|
||||
|
||||
<!-- inbound message info -->
|
||||
<div v-if="!chatItem.is_outbound" class="text-xs text-gray-500 mt-0.5 flex flex-col">
|
||||
<div v-if="!chatItem.is_outbound" class="text-xs text-gray-400 dark:text-zinc-500 mt-1.5 px-1 flex flex-col">
|
||||
|
||||
<!-- received timestamp -->
|
||||
<span @click="showReceivedMessageInfo(chatItem.lxmf_message)" class="cursor-pointer">{{ formatTimeAgo(chatItem.lxmf_message.created_at) }}</span>
|
||||
<span @click="toggleReceivedMessageInfo(chatItem.lxmf_message.hash)" class="cursor-pointer hover:underline">{{ formatTimeAgo(chatItem.lxmf_message.created_at) }}</span>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- expanded message details -->
|
||||
<div v-if="expandedMessageInfo === chatItem.lxmf_message.hash" class="mt-2 px-1 text-xs text-gray-500 dark:text-zinc-400 space-y-0.5">
|
||||
<div v-for="(line, index) in getMessageInfoLines(chatItem.lxmf_message, chatItem.is_outbound)" :key="index">{{ line }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- load previous -->
|
||||
<button v-show="!isLoadingPrevious && hasMorePrevious" id="load-previous" @click="loadPrevious" type="button" class="flex space-x-2 mx-auto bg-gray-200 px-3 py-1 hover:bg-gray-300 rounded-full shadow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<button v-show="!isLoadingPrevious && hasMorePrevious" id="load-previous" @click="loadPrevious" type="button" class="flex items-center gap-2 mx-auto mt-4 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 px-4 py-2 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-full shadow-sm text-sm font-medium text-gray-700 dark:text-zinc-300 transition-colors">
|
||||
<svg 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="m15 11.25-3-3m0 0-3 3m3-3v7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
<span>Load Previous</span>
|
||||
@@ -233,83 +279,60 @@
|
||||
</div>
|
||||
|
||||
<!-- send message -->
|
||||
<div class="w-full border-gray-300 dark:border-zinc-800 border-t p-2">
|
||||
<div class="mx-auto">
|
||||
<div class="w-full border-t border-gray-200/60 dark:border-zinc-800/60 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm px-3 sm:px-4 py-2.5">
|
||||
<div class="w-full">
|
||||
|
||||
<!-- blocked user notification -->
|
||||
<div v-if="isSelectedPeerBlocked" class="mb-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
<span class="text-sm text-yellow-800 dark:text-yellow-200">You have blocked this user. They cannot send you messages or establish links.</span>
|
||||
</div>
|
||||
|
||||
<!-- message composer -->
|
||||
<div>
|
||||
|
||||
<!-- image attachment -->
|
||||
<div v-if="newMessageImage" class="mb-2">
|
||||
<div @click.stop="openImage(newMessageImageUrl)" class="cursor-pointer w-32 h-32 rounded shadow border relative overflow-hidden">
|
||||
|
||||
<!-- image preview -->
|
||||
<img v-if="newMessageImageUrl" :src="newMessageImageUrl" class="w-full h-full object-cover"/>
|
||||
|
||||
<!-- remove button (top right) -->
|
||||
<div class="absolute top-0 right-0 p-1">
|
||||
<div @click.stop="removeImageAttachment" class="cursor-pointer">
|
||||
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<!-- image attachment -->
|
||||
<div v-if="newMessageImage" class="attachment-card">
|
||||
<div class="attachment-card__preview" @click.stop="openImage(newMessageImageUrl)">
|
||||
<img v-if="newMessageImageUrl" :src="newMessageImageUrl" class="w-full h-full object-cover rounded-lg"/>
|
||||
</div>
|
||||
|
||||
<!-- image size (bottom left) -->
|
||||
<div class="absolute bottom-0 left-0 p-1">
|
||||
<div class="bg-gray-100 rounded border text-sm px-1">{{ formatBytes(newMessageImage.size) }}</div>
|
||||
<div class="attachment-card__body">
|
||||
<div class="attachment-card__title">Image Attachment</div>
|
||||
<div class="attachment-card__meta">{{ formatBytes(newMessageImage.size) }}</div>
|
||||
</div>
|
||||
|
||||
<button @click.stop="removeImageAttachment" type="button" class="attachment-card__remove">
|
||||
<MaterialDesignIcon icon-name="close" class="w-4 h-4"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- audio attachment -->
|
||||
<div v-if="newMessageAudio" class="mb-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex border border-gray-300 dark:border-zinc-800 rounded text-gray-700 divide-x divide-gray-300 overflow-hidden">
|
||||
|
||||
<div class="flex p-1">
|
||||
|
||||
<!-- audio preview -->
|
||||
<div>
|
||||
<audio controls class="h-10">
|
||||
<source :src="newMessageAudio.audio_preview_url" type="audio/wav"/>
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<!-- encoded file size -->
|
||||
<div class="my-auto px-1 text-sm text-gray-500">
|
||||
{{ formatBytes(newMessageAudio.audio_blob.size) }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- remove audio attachment -->
|
||||
<div @click="removeAudioAttachment" class="flex my-auto text-sm text-gray-500 h-full px-1 hover:bg-gray-200 cursor-pointer">
|
||||
<svg class="w-5 h-5 my-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- audio attachment -->
|
||||
<div v-if="newMessageAudio" class="attachment-card">
|
||||
<div class="attachment-card__body w-full">
|
||||
<div class="attachment-card__title">Voice Note</div>
|
||||
<div class="attachment-card__meta">{{ formatBytes(newMessageAudio.audio_blob.size) }}</div>
|
||||
<audio controls class="w-full mt-2 rounded-lg shadow-sm audio-controls-dark" style="height:54px;">
|
||||
<source :src="newMessageAudio.audio_preview_url" type="audio/wav"/>
|
||||
</audio>
|
||||
</div>
|
||||
<button @click="removeAudioAttachment" type="button" class="attachment-card__remove">
|
||||
<MaterialDesignIcon icon-name="delete" class="w-4 h-4"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- file attachments -->
|
||||
<div v-if="newMessageFiles.length > 0" class="mb-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div v-for="file in newMessageFiles" class="flex border border-gray-300 dark:border-zinc-800 rounded text-gray-700 divide-x divide-gray-300 overflow-hidden dark:border-zinc-800">
|
||||
<div class="my-auto px-1">
|
||||
<span class="mr-1">{{ file.name }}</span>
|
||||
<span class="my-auto text-sm text-gray-500">{{ formatBytes(file.size) }}</span>
|
||||
</div>
|
||||
<div @click="removeFileAttachment(file)" class="flex my-auto text-sm text-gray-500 h-full px-1 hover:bg-gray-200 cursor-pointer">
|
||||
<svg class="w-5 h-5 my-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<!-- file attachments -->
|
||||
<div v-if="newMessageFiles.length > 0" class="flex flex-wrap gap-2">
|
||||
<div v-for="file in newMessageFiles" :key="file.name + file.size" class="attachment-chip">
|
||||
<div class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4 text-gray-500 dark:text-gray-300"/>
|
||||
<div class="text-sm text-gray-800 dark:text-gray-200 truncate max-w-[160px]">{{ file.name }}</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatBytes(file.size) }}</span>
|
||||
</div>
|
||||
<button @click="removeFileAttachment(file)" type="button" class="attachment-chip__remove">
|
||||
<MaterialDesignIcon icon-name="close" class="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,38 +345,23 @@
|
||||
v-model="newMessageText"
|
||||
@keydown.enter.exact.native.prevent="onEnterPressed"
|
||||
@keydown.enter.shift.exact.native.prevent="onShiftEnterPressed"
|
||||
class="bg-gray-50 border border-gray-300 dark:border-zinc-800 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:text-zinc-100 dark:border-zinc-900"
|
||||
rows="3"
|
||||
placeholder="Send a message..."></textarea>
|
||||
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 block w-full px-3 sm:px-4 py-2 resize-none shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
|
||||
rows="2"
|
||||
placeholder="Type a message..."></textarea>
|
||||
|
||||
<!-- action button -->
|
||||
<div class="flex mt-2">
|
||||
|
||||
<!-- add files -->
|
||||
<button @click="addFilesToMessage" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM12.75 12a.75.75 0 0 0-1.5 0v2.25H9a.75.75 0 0 0 0 1.5h2.25V18a.75.75 0 0 0 1.5 0v-2.25H15a.75.75 0 0 0 0-1.5h-2.25V12Z" clip-rule="evenodd" />
|
||||
<path d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z" />
|
||||
</svg>
|
||||
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Files</span>
|
||||
<div class="flex flex-wrap gap-2 items-center mt-2">
|
||||
<button @click="addFilesToMessage" type="button" class="attachment-action-button">
|
||||
<MaterialDesignIcon icon-name="paperclip-plus" class="w-4 h-4"/>
|
||||
<span>Add Files</span>
|
||||
</button>
|
||||
|
||||
<!-- add image -->
|
||||
<div>
|
||||
<AddImageButton @add-image="onImageSelected"/>
|
||||
</div>
|
||||
|
||||
<!-- add audio -->
|
||||
<div>
|
||||
<AddAudioButton
|
||||
:is-recording-audio-attachment="isRecordingAudioAttachment"
|
||||
@start-recording="startRecordingAudioAttachment($event)"
|
||||
@stop-recording="stopRecordingAudioAttachment">
|
||||
<span>Recording: {{ audioAttachmentRecordingDuration }}</span>
|
||||
</AddAudioButton>
|
||||
</div>
|
||||
|
||||
<!-- send message -->
|
||||
<AddImageButton @add-image="onImageSelected"/>
|
||||
<AddAudioButton
|
||||
:is-recording-audio-attachment="isRecordingAudioAttachment"
|
||||
@start-recording="startRecordingAudioAttachment($event)"
|
||||
@stop-recording="stopRecordingAudioAttachment">
|
||||
<span>Recording: {{ audioAttachmentRecordingDuration }}</span>
|
||||
</AddAudioButton>
|
||||
<div class="ml-auto my-auto">
|
||||
<SendMessageButton
|
||||
@send="sendMessage"
|
||||
@@ -362,7 +370,6 @@
|
||||
:can-send-message="canSendMessage"
|
||||
:delivery-method="newMessageDeliveryMethod"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -376,23 +383,53 @@
|
||||
</div>
|
||||
|
||||
<!-- no peer selected -->
|
||||
<div v-else class="flex flex-col mx-auto my-auto text-center leading-5">
|
||||
<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 dark:text-white">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold dark:text-white">No Active Chat</div>
|
||||
<div class='dark:text-zinc-300'>Select a Peer to start chatting!</div>
|
||||
<div class="mx-auto mt-2">
|
||||
<button @click.stop="openLXMFAddress" 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">
|
||||
Enter an LXMF Address
|
||||
</button>
|
||||
<div v-else class="flex flex-col h-full items-center justify-center">
|
||||
<div class="w-full max-w-md px-4">
|
||||
<div class="mb-6 text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-blue-100 to-blue-200 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-blue-600 dark:text-blue-400">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-zinc-100 mb-1">No Active Chat</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-zinc-400">Select a peer from the sidebar or enter an address below</p>
|
||||
</div>
|
||||
|
||||
<!-- compose message input -->
|
||||
<div class="w-full">
|
||||
<input
|
||||
ref="compose-input"
|
||||
id="compose-input"
|
||||
:readonly="isSendingMessage"
|
||||
v-model="composeAddress"
|
||||
@keydown.enter.exact.prevent="onComposeEnterPressed"
|
||||
type="text"
|
||||
class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
|
||||
placeholder="Enter LXMF address..."/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- image modal -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0">
|
||||
<div v-if="imageModalUrl" @click="closeImageModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 dark:bg-black/90 backdrop-blur-sm p-4">
|
||||
<div @click.stop class="relative max-w-7xl max-h-full">
|
||||
<button @click="closeImageModal" type="button" class="absolute -top-12 right-0 inline-flex items-center justify-center w-10 h-10 rounded-xl bg-white/10 dark:bg-zinc-900/10 hover:bg-white/20 dark:hover:bg-zinc-900/20 text-white transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<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>
|
||||
<img :src="imageModalUrl" class="max-w-full max-h-[90vh] rounded-xl shadow-2xl" alt="Image preview"/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -446,6 +483,7 @@ export default {
|
||||
newMessageFiles: [],
|
||||
isSendingMessage: false,
|
||||
autoScrollOnNewMessage: true,
|
||||
composeAddress: "",
|
||||
|
||||
isRecordingAudioAttachment: false,
|
||||
audioAttachmentMicrophoneRecorder: null,
|
||||
@@ -454,6 +492,10 @@ export default {
|
||||
audioAttachmentRecordingDuration: null,
|
||||
audioAttachmentRecordingTimer: null,
|
||||
lxmfMessageAudioAttachmentCache: {},
|
||||
expandedMessageInfo: null,
|
||||
imageModalUrl: null,
|
||||
isSelectedPeerBlocked: false,
|
||||
blockedDestinations: [],
|
||||
lxmfAudioModeToCodec2ModeMap: {
|
||||
// https://github.com/markqvist/LXMF/blob/master/LXMF/LXMF.py#L21
|
||||
0x01: "450PWB", // AM_CODEC2_450PWB
|
||||
@@ -472,14 +514,47 @@ export default {
|
||||
beforeUnmount() {
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
|
||||
},
|
||||
watch: {
|
||||
selectedPeer: {
|
||||
handler() {
|
||||
this.checkIfSelectedPeerBlocked();
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
||||
// listen for websocket messages
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
|
||||
// listen for compose new message event
|
||||
GlobalEmitter.on("compose-new-message", this.onComposeNewMessageEvent);
|
||||
|
||||
// load blocked destinations
|
||||
this.loadBlockedDestinations();
|
||||
|
||||
},
|
||||
methods: {
|
||||
async loadBlockedDestinations() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/blocked-destinations");
|
||||
this.blockedDestinations = response.data.blocked_destinations || [];
|
||||
this.checkIfSelectedPeerBlocked();
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
checkIfSelectedPeerBlocked() {
|
||||
if (!this.selectedPeer) {
|
||||
this.isSelectedPeerBlocked = false;
|
||||
return;
|
||||
}
|
||||
this.isSelectedPeerBlocked = this.blockedDestinations.some(
|
||||
b => b.destination_hash === this.selectedPeer.destination_hash
|
||||
);
|
||||
},
|
||||
close() {
|
||||
this.$emit("close");
|
||||
},
|
||||
@@ -614,6 +689,37 @@ export default {
|
||||
openLXMFAddress() {
|
||||
GlobalEmitter.emit("compose-new-message");
|
||||
},
|
||||
onComposeNewMessageEvent(destinationHash) {
|
||||
if(!this.selectedPeer && !destinationHash){
|
||||
this.$nextTick(() => {
|
||||
const composeInput = document.getElementById("compose-input");
|
||||
if(composeInput){
|
||||
composeInput.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
async onComposeSubmit() {
|
||||
if(!this.composeAddress || this.composeAddress.trim() === ""){
|
||||
return;
|
||||
}
|
||||
let destinationHash = this.composeAddress.trim();
|
||||
this.composeAddress = "";
|
||||
await this.handleComposeAddress(destinationHash);
|
||||
},
|
||||
onComposeEnterPressed() {
|
||||
this.onComposeSubmit();
|
||||
},
|
||||
async handleComposeAddress(destinationHash) {
|
||||
if(destinationHash.startsWith("lxmf@")){
|
||||
destinationHash = destinationHash.replace("lxmf@", "");
|
||||
}
|
||||
if(destinationHash.length !== 32){
|
||||
DialogUtils.alert("Invalid Address");
|
||||
return;
|
||||
}
|
||||
GlobalEmitter.emit("compose-new-message", destinationHash);
|
||||
},
|
||||
onLxmfMessageReceived(lxmfMessage) {
|
||||
|
||||
// add inbound message to ui
|
||||
@@ -862,16 +968,10 @@ export default {
|
||||
}
|
||||
},
|
||||
openImage: async function(url) {
|
||||
|
||||
// convert data uri to blob
|
||||
const blob = await (await fetch(url)).blob();
|
||||
|
||||
// create blob url
|
||||
const fileUrl = window.URL.createObjectURL(blob);
|
||||
|
||||
// open new tab
|
||||
window.open(fileUrl);
|
||||
|
||||
this.imageModalUrl = url;
|
||||
},
|
||||
closeImageModal() {
|
||||
this.imageModalUrl = null;
|
||||
},
|
||||
downloadFileFromBase64: async function(fileName, fileBytesBase64) {
|
||||
|
||||
@@ -1205,6 +1305,23 @@ export default {
|
||||
formatBytes: function(bytes) {
|
||||
return Utils.formatBytes(bytes);
|
||||
},
|
||||
base64ByteLength(base64String) {
|
||||
if(!base64String){
|
||||
return 0;
|
||||
}
|
||||
const padding = (base64String.match(/=+$/) || [""])[0].length;
|
||||
return Math.floor(base64String.length * 3 / 4) - padding;
|
||||
},
|
||||
formatBase64Bytes(base64String) {
|
||||
return this.formatBytes(this.base64ByteLength(base64String));
|
||||
},
|
||||
openConversationPopout() {
|
||||
if (!this.selectedPeer) return;
|
||||
const destinationHash = this.selectedPeer.destination_hash || "";
|
||||
const encodedHash = encodeURIComponent(destinationHash);
|
||||
const url = `${window.location.origin}${window.location.pathname}#/popout/messages/${encodedHash}`;
|
||||
window.open(url, "_blank", "width=960,height=720,noopener");
|
||||
},
|
||||
onFileInputChange: function(event) {
|
||||
for(const file of event.target.files){
|
||||
this.newMessageFiles.push(file);
|
||||
@@ -1464,89 +1581,64 @@ export default {
|
||||
this.$emit("reload-conversations");
|
||||
|
||||
},
|
||||
showSentMessageInfo: function(lxmfMessage) {
|
||||
|
||||
// basic info
|
||||
const info = [
|
||||
`Created: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`,
|
||||
`Method: ${lxmfMessage.method ?? "unknown"}`,
|
||||
];
|
||||
|
||||
// add audio attachment size
|
||||
if(lxmfMessage.fields?.audio?.audio_bytes){
|
||||
const audioBytesLength = atob(lxmfMessage.fields?.audio?.audio_bytes).length;
|
||||
info.push(`Audio Attachment: ${this.formatBytes(audioBytesLength)}`);
|
||||
toggleSentMessageInfo: function(messageHash) {
|
||||
if(this.expandedMessageInfo === messageHash){
|
||||
this.expandedMessageInfo = null;
|
||||
} else {
|
||||
this.expandedMessageInfo = messageHash;
|
||||
}
|
||||
|
||||
// add image attachment size
|
||||
if(lxmfMessage.fields?.image?.image_bytes){
|
||||
const imageBytesLength = atob(lxmfMessage.fields?.image?.image_bytes).length;
|
||||
info.push(`Image Attachment: ${this.formatBytes(imageBytesLength)}`);
|
||||
}
|
||||
|
||||
// add file attachments size
|
||||
if(lxmfMessage.fields?.file_attachments){
|
||||
var filesLength = 0;
|
||||
for(const fileAttachment of lxmfMessage.fields?.file_attachments){
|
||||
const fileBytesLength = atob(fileAttachment.file_bytes).length;
|
||||
filesLength += fileBytesLength;
|
||||
}
|
||||
info.push(`File Attachments: ${this.formatBytes(filesLength)}`);
|
||||
}
|
||||
|
||||
// show message info
|
||||
DialogUtils.alert(info.join("\n"));
|
||||
|
||||
},
|
||||
showReceivedMessageInfo: function(lxmfMessage) {
|
||||
toggleReceivedMessageInfo: function(messageHash) {
|
||||
if(this.expandedMessageInfo === messageHash){
|
||||
this.expandedMessageInfo = null;
|
||||
} else {
|
||||
this.expandedMessageInfo = messageHash;
|
||||
}
|
||||
},
|
||||
getMessageInfoLines: function(lxmfMessage, isOutbound) {
|
||||
const lines = [];
|
||||
|
||||
// basic info
|
||||
const info = [
|
||||
`Sent: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`,
|
||||
`Received: ${Utils.convertDateTimeToLocalDateTimeString(new Date(lxmfMessage.created_at))}`,
|
||||
`Method: ${lxmfMessage.method ?? "unknown"}`,
|
||||
];
|
||||
if(isOutbound){
|
||||
lines.push(`Created: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`);
|
||||
} else {
|
||||
lines.push(`Sent: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`);
|
||||
lines.push(`Received: ${Utils.convertDateTimeToLocalDateTimeString(new Date(lxmfMessage.created_at))}`);
|
||||
}
|
||||
|
||||
lines.push(`Method: ${lxmfMessage.method ?? "unknown"}`);
|
||||
|
||||
// add audio attachment size
|
||||
if(lxmfMessage.fields?.audio?.audio_bytes){
|
||||
const audioBytesLength = atob(lxmfMessage.fields?.audio?.audio_bytes).length;
|
||||
info.push(`Audio Attachment: ${this.formatBytes(audioBytesLength)}`);
|
||||
lines.push(`Audio Attachment: ${this.formatBytes(audioBytesLength)}`);
|
||||
}
|
||||
|
||||
// add image attachment size
|
||||
if(lxmfMessage.fields?.image?.image_bytes){
|
||||
const imageBytesLength = atob(lxmfMessage.fields?.image?.image_bytes).length;
|
||||
info.push(`Image Attachment: ${this.formatBytes(imageBytesLength)}`);
|
||||
lines.push(`Image Attachment: ${this.formatBytes(imageBytesLength)}`);
|
||||
}
|
||||
|
||||
// add file attachments size
|
||||
if(lxmfMessage.fields?.file_attachments){
|
||||
var filesLength = 0;
|
||||
for(const fileAttachment of lxmfMessage.fields?.file_attachments){
|
||||
const fileBytesLength = atob(fileAttachment.file_bytes).length;
|
||||
filesLength += fileBytesLength;
|
||||
}
|
||||
info.push(`File Attachments: ${this.formatBytes(filesLength)}`);
|
||||
lines.push(`File Attachments: ${this.formatBytes(filesLength)}`);
|
||||
}
|
||||
|
||||
// add signal quality if available
|
||||
if(lxmfMessage.quality != null){
|
||||
info.push(`Signal Quality: ${lxmfMessage.quality}%`);
|
||||
if(!isOutbound){
|
||||
if(lxmfMessage.quality != null){
|
||||
lines.push(`Signal Quality: ${lxmfMessage.quality}%`);
|
||||
}
|
||||
if(lxmfMessage.rssi != null){
|
||||
lines.push(`RSSI: ${lxmfMessage.rssi}dBm`);
|
||||
}
|
||||
if(lxmfMessage.snr != null){
|
||||
lines.push(`SNR: ${lxmfMessage.snr}dB`);
|
||||
}
|
||||
}
|
||||
|
||||
// add rssi if available
|
||||
if(lxmfMessage.rssi != null){
|
||||
info.push(`RSSI: ${lxmfMessage.rssi}dBm`);
|
||||
}
|
||||
|
||||
// add snr if available
|
||||
if(lxmfMessage.snr != null){
|
||||
info.push(`SNR: ${lxmfMessage.snr}dB`);
|
||||
}
|
||||
|
||||
// show message info
|
||||
DialogUtils.alert(info.join("\n"));
|
||||
|
||||
return lines;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
@@ -1618,3 +1710,57 @@ export default {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attachment-card {
|
||||
@apply relative flex gap-3 border border-gray-200 dark:border-zinc-800 rounded-2xl p-3 shadow-sm;
|
||||
background-color: white;
|
||||
}
|
||||
.dark .attachment-card {
|
||||
background-color: rgb(24 24 27);
|
||||
}
|
||||
.attachment-card__preview {
|
||||
@apply w-24 h-24 overflow-hidden rounded-xl bg-gray-100 dark:bg-zinc-800 cursor-pointer;
|
||||
}
|
||||
.attachment-card__body {
|
||||
@apply flex-1;
|
||||
}
|
||||
.attachment-card__title {
|
||||
@apply text-sm font-semibold text-gray-800 dark:text-gray-100;
|
||||
}
|
||||
.attachment-card__meta {
|
||||
@apply text-xs text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
.attachment-card__remove {
|
||||
@apply absolute top-2 right-2 inline-flex items-center justify-center w-6 h-6 rounded-full bg-gray-200 dark:bg-zinc-800 text-gray-600 dark:text-gray-200 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/40;
|
||||
}
|
||||
.attachment-chip {
|
||||
@apply flex items-center justify-between gap-2 border border-gray-200 dark:border-zinc-800 rounded-full px-3 py-1 text-xs shadow-sm;
|
||||
background-color: white;
|
||||
}
|
||||
.dark .attachment-chip {
|
||||
background-color: rgb(24 24 27);
|
||||
}
|
||||
.attachment-chip__remove {
|
||||
@apply inline-flex items-center justify-center text-gray-500 dark:text-gray-300 hover:text-red-500;
|
||||
}
|
||||
.attachment-action-button {
|
||||
@apply inline-flex items-center gap-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;
|
||||
}
|
||||
|
||||
.audio-controls-light {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
|
||||
.dark .audio-controls-light {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.audio-controls-dark {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.dark .audio-controls-dark {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,21 @@
|
||||
<template>
|
||||
|
||||
<MessagesSidebar
|
||||
v-if="!isPopoutMode"
|
||||
: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"/>
|
||||
@peer-click="onPeerClick"
|
||||
@conversation-search-changed="onConversationSearchChanged"
|
||||
@conversation-filter-changed="onConversationFilterChanged"/>
|
||||
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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">
|
||||
|
||||
<!-- messages tab -->
|
||||
<ConversationViewer
|
||||
@@ -45,6 +53,7 @@ export default {
|
||||
return {
|
||||
|
||||
reloadInterval: null,
|
||||
conversationRefreshTimeout: null,
|
||||
|
||||
config: null,
|
||||
peers: {},
|
||||
@@ -53,11 +62,18 @@ export default {
|
||||
conversations: [],
|
||||
lxmfDeliveryAnnounces: [],
|
||||
|
||||
conversationSearchTerm: "",
|
||||
filterUnreadOnly: false,
|
||||
filterFailedOnly: false,
|
||||
filterHasAttachmentsOnly: false,
|
||||
isLoadingConversations: false,
|
||||
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
|
||||
clearInterval(this.reloadInterval);
|
||||
clearTimeout(this.conversationRefreshTimeout);
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
@@ -87,37 +103,36 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async onComposeNewMessage(destinationHash) {
|
||||
|
||||
// ask for destination address if not provided
|
||||
if(destinationHash == null){
|
||||
destinationHash = await DialogUtils.prompt("Enter LXMF Address");
|
||||
if(!destinationHash){
|
||||
if(this.selectedPeer){
|
||||
return;
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
const composeInput = document.getElementById("compose-input");
|
||||
if(composeInput){
|
||||
composeInput.focus();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// if user provided an address with an "lxmf@" prefix, lets remove that to get the raw destination hash
|
||||
if(destinationHash.startsWith("lxmf@")){
|
||||
destinationHash = destinationHash.replace("lxmf@", "");
|
||||
}
|
||||
|
||||
// fetch updated announce as we might be composing new message before we loaded the announces list
|
||||
await this.getLxmfDeliveryAnnounce(destinationHash);
|
||||
|
||||
// attempt to find existing peer so we can show their name
|
||||
const existingPeer = this.peers[destinationHash];
|
||||
if(existingPeer){
|
||||
this.onPeerClick(existingPeer);
|
||||
return;
|
||||
}
|
||||
|
||||
// simple attempt to prevent garbage input
|
||||
if(destinationHash.length !== 32){
|
||||
DialogUtils.alert("Invalid Address");
|
||||
return;
|
||||
}
|
||||
|
||||
// we didn't find an existing peer, so just use an unknown name
|
||||
this.onPeerClick({
|
||||
display_name: "Unknown Peer",
|
||||
destination_hash: destinationHash,
|
||||
@@ -200,13 +215,34 @@ export default {
|
||||
},
|
||||
async getConversations() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
|
||||
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;
|
||||
},
|
||||
@@ -216,12 +252,17 @@ export default {
|
||||
this.selectedPeer = peer;
|
||||
|
||||
// update current route
|
||||
this.$router.replace({
|
||||
name: "messages",
|
||||
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) {
|
||||
@@ -238,11 +279,57 @@ export default {
|
||||
// clear selected peer
|
||||
this.selectedPeer = null;
|
||||
|
||||
// update current route
|
||||
this.$router.replace({
|
||||
name: "messages",
|
||||
});
|
||||
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;
|
||||
},
|
||||
},
|
||||
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: {
|
||||
@@ -2,25 +2,40 @@
|
||||
<div class="flex flex-col w-80 min-w-80">
|
||||
|
||||
<!-- tabs -->
|
||||
<div class="bg-white dark:bg-zinc-950 border-b border-r border-gray-200 dark:border-zinc-700">
|
||||
<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 @click="tab = 'conversations'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'conversations' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Conversations</div>
|
||||
<div @click="tab = 'announces'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'announces' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Announces</div>
|
||||
<div @click="tab = 'conversations'" 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']">Conversations</div>
|
||||
<div @click="tab = 'announces'" 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']">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">
|
||||
<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 -->
|
||||
<div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
|
||||
<input v-model="conversationsSearchTerm" type="text" :placeholder="`Search ${conversations.length} Conversations...`" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
<!-- 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"
|
||||
@input="onConversationSearchInput"
|
||||
type="text"
|
||||
:placeholder="`Search ${conversations.length} conversations...`"
|
||||
class="input-field">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button type="button" @click="toggleFilter('unread')" :class="filterChipClasses(filterUnreadOnly)">Unread</button>
|
||||
<button type="button" @click="toggleFilter('failed')" :class="filterChipClasses(filterFailedOnly)">Failed</button>
|
||||
<button type="button" @click="toggleFilter('attachments')" :class="filterChipClasses(filterHasAttachmentsOnly)">Attachments</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- peers -->
|
||||
<!-- conversations -->
|
||||
<div class="flex h-full overflow-y-auto">
|
||||
<div v-if="searchedConversations.length > 0" class="w-full">
|
||||
<div @click="onConversationClick(conversation)" v-for="conversation of searchedConversations" 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' ]">
|
||||
<div v-if="displayedConversations.length > 0" class="w-full">
|
||||
<div
|
||||
v-for="conversation of displayedConversations"
|
||||
:key="conversation.destination_hash"
|
||||
@click="onConversationClick(conversation)"
|
||||
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' ]">
|
||||
<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"/>
|
||||
@@ -29,22 +44,45 @@
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mr-auto">
|
||||
<div class="text-gray-900 dark:text-gray-100" :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-sm">{{ formatTimeAgo(conversation.updated_at) }}</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 v-if="conversation.is_unread" class="my-auto ml-2 mr-2">
|
||||
<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-2 mr-2">
|
||||
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></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-if="conversations.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<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" />
|
||||
@@ -55,25 +93,25 @@
|
||||
</div>
|
||||
|
||||
<!-- is searching, but no results -->
|
||||
<div v-if="conversationsSearchTerm !== '' && conversations.length > 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<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">No Search Results</div>
|
||||
<div>Your search didn't match any Conversations!</div>
|
||||
<div>Your search didn't match any 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">
|
||||
<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="`Search ${peersCount} recent announces...`" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
<input v-model="peersSearchTerm" type="text" :placeholder="`Search ${peersCount} recent announces...`" class="input-field">
|
||||
</div>
|
||||
|
||||
<!-- peers -->
|
||||
@@ -88,8 +126,8 @@
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ peer.custom_display_name ?? peer.display_name }}</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 -->
|
||||
@@ -150,15 +188,35 @@ import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: 'MessagesSidebar',
|
||||
components: {MaterialDesignIcon},
|
||||
emits: ["conversation-click", "peer-click", "conversation-search-changed", "conversation-filter-changed"],
|
||||
props: {
|
||||
peers: Object,
|
||||
conversations: Array,
|
||||
selectedDestinationHash: String,
|
||||
conversationSearchTerm: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
filterUnreadOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterFailedOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterHasAttachmentsOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tab: "conversations",
|
||||
conversationsSearchTerm: "",
|
||||
peersSearchTerm: "",
|
||||
};
|
||||
},
|
||||
@@ -172,16 +230,23 @@ export default {
|
||||
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`;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
searchedConversations() {
|
||||
return this.conversations.filter((conversation) => {
|
||||
const search = this.conversationsSearchTerm.toLowerCase();
|
||||
const matchesDisplayName = conversation.display_name.toLowerCase().includes(search);
|
||||
const matchesCustomDisplayName = conversation.custom_display_name?.toLowerCase()?.includes(search) === true;
|
||||
const matchesDestinationHash = conversation.destination_hash.toLowerCase().includes(search);
|
||||
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
|
||||
});
|
||||
displayedConversations() {
|
||||
return this.conversations;
|
||||
},
|
||||
peersCount() {
|
||||
return Object.keys(this.peers).length;
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="relative inline-flex rounded-xl shadow-sm">
|
||||
<!-- send button -->
|
||||
<button @click="send" :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']">
|
||||
<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">
|
||||
<!-- dropdown button -->
|
||||
<button @click="showMenu" :disabled="!canSendMessage" type="button" class="border-l relative inline-flex items-center justify-center rounded-r-xl px-2.5 py-2.5 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']">
|
||||
<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 @click="setDeliveryMethod(null)" 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">Send Automatically</button>
|
||||
<button @click="setDeliveryMethod('direct')" 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">Send over Direct Link</button>
|
||||
<button @click="setDeliveryMethod('opportunistic')" 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">Send Opportunistically</button>
|
||||
<button @click="setDeliveryMethod('propagated')" 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">Send to Propagation Node</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SendMessageButton',
|
||||
props: {
|
||||
deliveryMethod: String,
|
||||
canSendMessage: Boolean,
|
||||
isSendingMessage: Boolean,
|
||||
},
|
||||
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>
|
||||
@@ -3,38 +3,41 @@
|
||||
<!-- network -->
|
||||
<div id="network" class="w-full h-full"></div>
|
||||
<!-- controls -->
|
||||
<div class="absolute flex bottom-0 left-0 bg-gray-100 dark:bg-zinc-900 p-2">
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow min-w-52">
|
||||
<div @click="isShowingControls = !isShowingControls" class="flex text-gray-700 dark:text-gray-300 p-2 cursor-pointer">
|
||||
<div class="my-auto">Reticulum Network</div>
|
||||
<div class="flex ml-auto">
|
||||
<button
|
||||
@click.stop="update"
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-1 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-600"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 text-white">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
<div class="absolute bottom-4 left-4 z-10">
|
||||
<div class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-2xl shadow-lg overflow-hidden min-w-[240px]">
|
||||
<div @click="isShowingControls = !isShowingControls" class="flex items-center px-4 py-3 border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors">
|
||||
<div class="flex-1 font-semibold text-gray-900 dark:text-zinc-100">Reticulum Network</div>
|
||||
<button
|
||||
@click.stop="update"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white shadow-sm transition-colors"
|
||||
:disabled="isUpdating"
|
||||
>
|
||||
<svg v-if="!isUpdating" 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="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" 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>
|
||||
<div v-if="isShowingControls" class="divide-y dark:divide-zinc-700 text-gray-900 dark:text-white border-t border-gray-300 dark:border-zinc-700">
|
||||
<div class="px-1 py-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
v-model="autoReload"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-900 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-800"
|
||||
>
|
||||
</div>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Auto Update (5 sec)</label>
|
||||
</div>
|
||||
<div v-if="isShowingControls" class="px-4 py-3 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="autoReload"
|
||||
type="checkbox"
|
||||
id="auto-reload"
|
||||
class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-white dark:bg-zinc-900 text-blue-600 focus:ring-2 focus:ring-blue-500/50 focus:ring-offset-0"
|
||||
>
|
||||
<label for="auto-reload" class="text-sm font-medium text-gray-900 dark:text-zinc-100 cursor-pointer">Auto Update (5 sec)</label>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<div class="text-black dark:text-white">Interfaces</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">{{ onlineInterfaces.length }} Online, {{ offlineInterfaces.length }} Offline</div>
|
||||
<div class="pt-2 border-t border-gray-200 dark:border-zinc-800">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-zinc-100 mb-1">Interfaces</div>
|
||||
<div class="text-xs text-gray-600 dark:text-zinc-400">
|
||||
<span class="text-green-600 dark:text-green-400 font-medium">{{ onlineInterfaces.length }}</span> Online,
|
||||
<span class="text-red-600 dark:text-red-400 font-medium">{{ offlineInterfaces.length }}</span> Offline
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,7 +48,24 @@
|
||||
<style>
|
||||
.vis-tooltip {
|
||||
color: white !important;
|
||||
background: rgba(0, 0, 0, 0.75) !important;
|
||||
background: rgba(0, 0, 0, 0.85) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
font-size: 0.875rem !important;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
|
||||
.dark .vis-tooltip {
|
||||
background: rgba(24, 24, 27, 0.95) !important;
|
||||
border: 1px solid rgba(63, 63, 70, 0.5) !important;
|
||||
}
|
||||
|
||||
#network {
|
||||
background-color: rgb(249, 250, 251);
|
||||
}
|
||||
|
||||
.dark #network {
|
||||
background-color: rgb(9, 9, 11);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,6 +73,7 @@
|
||||
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";
|
||||
export default {
|
||||
name: 'NetworkVisualiser',
|
||||
@@ -62,12 +83,15 @@ export default {
|
||||
autoReload: false,
|
||||
reloadInterval: null,
|
||||
isShowingControls: true,
|
||||
isUpdating: false,
|
||||
interfaces: [],
|
||||
pathTable: [],
|
||||
announces: {},
|
||||
conversations: {},
|
||||
network: null,
|
||||
nodes: new DataSet(),
|
||||
edges: new DataSet(),
|
||||
iconCache: {},
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
@@ -118,6 +142,70 @@ export default {
|
||||
console.log(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.log(e);
|
||||
}
|
||||
},
|
||||
async createIconImage(iconName, foregroundColor, backgroundColor, size = 32) {
|
||||
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
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, size / 2 - 1, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
// 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.2, size * 0.2, size * 0.6, size * 0.6);
|
||||
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 createMdiIconImage(iconName, size = 32) {
|
||||
const foregroundColor = '#ffffff';
|
||||
const backgroundColor = '#6b7280';
|
||||
return await this.createIconImage(iconName, foregroundColor, backgroundColor, size);
|
||||
},
|
||||
async init() {
|
||||
|
||||
// create network ui
|
||||
@@ -135,11 +223,23 @@ export default {
|
||||
},
|
||||
nodes: {
|
||||
color: {
|
||||
border: "#000000",
|
||||
border: "#e5e7eb",
|
||||
highlight: {
|
||||
border: "#000000",
|
||||
border: "#3b82f6",
|
||||
},
|
||||
},
|
||||
font: {
|
||||
color: "#111827",
|
||||
size: 14,
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
},
|
||||
},
|
||||
edges: {
|
||||
color: {
|
||||
color: "#9ca3af",
|
||||
highlight: "#3b82f6",
|
||||
},
|
||||
width: 2,
|
||||
},
|
||||
physics: {
|
||||
barnesHut: {
|
||||
@@ -256,14 +356,24 @@ export default {
|
||||
},
|
||||
async update() {
|
||||
|
||||
await this.getConfig();
|
||||
await this.getInterfaceStats();
|
||||
await this.getPathTable();
|
||||
await this.getAnnounces();
|
||||
this.isUpdating = true;
|
||||
try {
|
||||
await this.getConfig();
|
||||
await this.getInterfaceStats();
|
||||
await this.getPathTable();
|
||||
await this.getAnnounces();
|
||||
await this.getConversations();
|
||||
} finally {
|
||||
this.isUpdating = false;
|
||||
}
|
||||
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
const fontColor = isDarkMode ? "#f4f4f5" : "#111827";
|
||||
const fontBackground = isDarkMode ? "rgba(24, 24, 27, 0.9)" : "rgba(255, 255, 255, 0.9)";
|
||||
|
||||
// add me
|
||||
nodes.push({
|
||||
id: "me",
|
||||
@@ -275,8 +385,8 @@ export default {
|
||||
`Identity: ${this.config?.identity_hash ?? 'Unknown'}`,
|
||||
].join("\n"),
|
||||
font: {
|
||||
color: "#000000",
|
||||
background: "#ffffff",
|
||||
color: fontColor,
|
||||
background: fontBackground,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -305,8 +415,8 @@ export default {
|
||||
].join("\n"),
|
||||
size: 30,
|
||||
font: {
|
||||
color: "#000000",
|
||||
background: '#ffffff',
|
||||
color: fontColor,
|
||||
background: fontBackground,
|
||||
},
|
||||
shape: "circularImage",
|
||||
image: entry.status ? "/assets/images/network-visualiser/interface_connected.png" : "/assets/images/network-visualiser/interface_disconnected.png",
|
||||
@@ -322,12 +432,9 @@ export default {
|
||||
id: `${entry.parent_interface_name}~${entry.name}`,
|
||||
from: entry.parent_interface_name,
|
||||
to: entry.name,
|
||||
color: "transparent",
|
||||
color: entry.status ? "#22c55e" : "#ef4444",
|
||||
width: 3,
|
||||
length: 300,
|
||||
background: {
|
||||
enabled: true,
|
||||
color: entry.status ? "#22c55e" : "#ef4444",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// add edge from me to interface
|
||||
@@ -335,12 +442,9 @@ export default {
|
||||
id: `me~${entry.name}`,
|
||||
from: "me",
|
||||
to: entry.name,
|
||||
color: "transparent",
|
||||
color: entry.status ? "#22c55e" : "#ef4444",
|
||||
width: 3,
|
||||
length: 300,
|
||||
background: {
|
||||
enabled: true,
|
||||
color: entry.status ? "#22c55e" : "#ef4444",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,9 +479,22 @@ export default {
|
||||
if(announce.aspect === "lxmf.delivery"){
|
||||
|
||||
const name = announce.custom_display_name ?? announce.display_name;
|
||||
const conversation = this.conversations[announce.destination_hash];
|
||||
|
||||
node.shape = "circularImage";
|
||||
node.image = entry.hops === 1 ? "/assets/images/network-visualiser/user_1hop.png" : "/assets/images/network-visualiser/user.png";
|
||||
|
||||
if(conversation?.lxmf_user_icon){
|
||||
const iconImage = await this.createIconImage(
|
||||
conversation.lxmf_user_icon.icon_name,
|
||||
conversation.lxmf_user_icon.foreground_colour,
|
||||
conversation.lxmf_user_icon.background_colour,
|
||||
40
|
||||
);
|
||||
node.image = iconImage;
|
||||
node.size = 30;
|
||||
} else {
|
||||
node.image = entry.hops === 1 ? "/assets/images/network-visualiser/user_1hop.png" : "/assets/images/network-visualiser/user.png";
|
||||
}
|
||||
|
||||
node.label = name;
|
||||
node.title = [
|
||||
@@ -423,7 +540,8 @@ export default {
|
||||
id: `${entry.interface}~${entry.hash}`,
|
||||
from: entry.interface,
|
||||
to: entry.hash,
|
||||
color: "gray",
|
||||
color: isDarkMode ? "#71717a" : "#9ca3af",
|
||||
width: 2,
|
||||
});
|
||||
|
||||
}
|
||||
@@ -504,3 +622,4 @@ export default {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<!-- nomadnetwork sidebar -->
|
||||
<NomadNetworkSidebar
|
||||
v-if="!isPopoutMode"
|
||||
:nodes="nodes"
|
||||
:favourites="favourites"
|
||||
:selected-destination-hash="selectedNode?.destination_hash"
|
||||
@@ -11,7 +12,7 @@
|
||||
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<!-- node -->
|
||||
<div v-if="selectedNode" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
|
||||
<div v-if="selectedNode" class="flex flex-col h-full min-h-0 bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
|
||||
<!-- header -->
|
||||
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
|
||||
|
||||
@@ -38,9 +39,9 @@
|
||||
</div>
|
||||
|
||||
<!-- node info -->
|
||||
<div class="my-auto dark:text-gray-100">
|
||||
<span class="font-semibold">{{ selectedNode.display_name }}</span>
|
||||
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
|
||||
<div class="my-auto dark:text-gray-100 flex-1 min-w-0 flex items-baseline gap-1">
|
||||
<span class="font-semibold truncate inline-block max-w-xs sm:max-w-sm" :title="selectedNode.display_name">{{ selectedNode.display_name }}</span>
|
||||
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer whitespace-nowrap"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
|
||||
</div>
|
||||
|
||||
<!-- identify button -->
|
||||
@@ -56,6 +57,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- popout button -->
|
||||
<div class="my-auto mr-2">
|
||||
<div @click="openNomadnetPopout" class="cursor-pointer">
|
||||
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path d="M17 3.75h3.25A.75.75 0 0 1 21 4.5v3.25a.75.75 0 0 1-1.5 0V6.31l-4.97 4.97a.75.75 0 1 1-1.06-1.06l4.97-4.97H17a.75.75 0 0 1 0-1.5Z"/>
|
||||
<path d="M5.25 6A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75v-6a.75.75 0 0 0-1.5 0v6c0 .414-.336.75-.75.75H5.25a.75.75 0 0 1-.75-.75V8.25c0-.414.336-.75.75-.75h6a.75.75 0 0 0 0-1.5h-6Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- close button -->
|
||||
<div class="my-auto mr-2">
|
||||
<div @click="onCloseNodeViewer" class="cursor-pointer">
|
||||
@@ -103,7 +118,7 @@
|
||||
</div>
|
||||
|
||||
<!-- page content -->
|
||||
<div class="h-full overflow-y-scroll p-3 bg-black text-white nodeContainer">
|
||||
<div class="flex-1 overflow-y-auto p-3 bg-black text-white nodeContainer">
|
||||
<div class="flex" v-if="isLoadingNodePage">
|
||||
<div class="my-auto">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@@ -111,7 +126,10 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="my-auto">Loading {{ nodePageProgress }}%</div>
|
||||
<div class="my-auto flex-1">Loading {{ nodePageProgress }}%</div>
|
||||
<button @click="cancelPageDownload" type="button" class="my-auto text-white bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 rounded px-3 py-1 text-sm font-semibold cursor-pointer ml-3">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<pre v-else v-html="renderedNodePageContent()" class="h-full break-words whitespace-pre-wrap"></pre>
|
||||
</div>
|
||||
@@ -124,12 +142,15 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<div class="my-auto flex-1">
|
||||
Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)
|
||||
<span v-if="nodeFileDownloadSpeed !== null" class="ml-2 text-sm">
|
||||
- {{ formatBytesPerSecond(nodeFileDownloadSpeed) }}
|
||||
</span>
|
||||
</div>
|
||||
<button @click="cancelFileDownload" type="button" class="my-auto text-white bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 rounded px-3 py-1 text-sm font-semibold cursor-pointer">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -215,6 +236,7 @@ export default {
|
||||
nodePageProgress: 0,
|
||||
nodePagePathHistory: [],
|
||||
nodePageCache: {},
|
||||
currentPageDownloadId: null,
|
||||
|
||||
isDownloadingNodeFile: false,
|
||||
nodeFilePath: null,
|
||||
@@ -223,6 +245,7 @@ export default {
|
||||
nodeFileLastProgressTime: null,
|
||||
nodeFileLastProgressValue: 0,
|
||||
nodeFileDownloadSpeed: null,
|
||||
currentFileDownloadId: null,
|
||||
|
||||
nomadnetPageDownloadCallbacks: {},
|
||||
nomadnetFileDownloadCallbacks: {},
|
||||
@@ -266,7 +289,27 @@ export default {
|
||||
}, 5000);
|
||||
|
||||
},
|
||||
computed: {
|
||||
popoutRouteType() {
|
||||
if(this.$route?.meta?.popoutType){
|
||||
return this.$route.meta.popoutType;
|
||||
}
|
||||
return this.$route?.query?.popout ?? this.getHashPopoutValue();
|
||||
},
|
||||
isPopoutMode() {
|
||||
return this.popoutRouteType === "nomad";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openNomadnetPopout() {
|
||||
if (!this.selectedNode) {
|
||||
return;
|
||||
}
|
||||
const destinationHash = this.selectedNode.destination_hash || "";
|
||||
const encodedHash = encodeURIComponent(destinationHash);
|
||||
const url = `${window.location.origin}${window.location.pathname}#/popout/nomadnetwork/${encodedHash}`;
|
||||
window.open(url, "_blank", "width=1100,height=800,noopener");
|
||||
},
|
||||
onElementClick(event) {
|
||||
|
||||
// find the closest ancestor (or the clicked element itself) with data-action="openNode"
|
||||
@@ -297,6 +340,13 @@ export default {
|
||||
|
||||
// get data from server
|
||||
const nomadnetPageDownload = json.nomadnet_page_download;
|
||||
const downloadId = json.download_id;
|
||||
|
||||
// handle started status
|
||||
if(nomadnetPageDownload.status === "started"){
|
||||
this.currentPageDownloadId = downloadId;
|
||||
return;
|
||||
}
|
||||
|
||||
// find download callbacks
|
||||
const getNomadnetPageDownloadCallbackKey = this.getNomadnetPageDownloadCallbackKey(nomadnetPageDownload.destination_hash, nomadnetPageDownload.page_path);
|
||||
@@ -310,6 +360,7 @@ export default {
|
||||
if(nomadnetPageDownload.status === "success" && nomadnetPageDownloadCallback.onSuccessCallback){
|
||||
nomadnetPageDownloadCallback.onSuccessCallback(nomadnetPageDownload.page_content);
|
||||
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
||||
this.currentPageDownloadId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -317,6 +368,7 @@ export default {
|
||||
if(nomadnetPageDownload.status === "failure" && nomadnetPageDownloadCallback.onFailureCallback){
|
||||
nomadnetPageDownloadCallback.onFailureCallback(nomadnetPageDownload.failure_reason);
|
||||
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
||||
this.currentPageDownloadId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -333,6 +385,13 @@ export default {
|
||||
|
||||
// get data from server
|
||||
const nomadnetFileDownload = json.nomadnet_file_download;
|
||||
const downloadId = json.download_id;
|
||||
|
||||
// handle started status
|
||||
if(nomadnetFileDownload.status === "started"){
|
||||
this.currentFileDownloadId = downloadId;
|
||||
return;
|
||||
}
|
||||
|
||||
// find download callbacks
|
||||
const getNomadnetFileDownloadCallbackKey = this.getNomadnetFileDownloadCallbackKey(nomadnetFileDownload.destination_hash, nomadnetFileDownload.file_path);
|
||||
@@ -346,6 +405,7 @@ export default {
|
||||
if(nomadnetFileDownload.status === "success" && nomadnetFileDownloadCallback.onSuccessCallback){
|
||||
nomadnetFileDownloadCallback.onSuccessCallback(nomadnetFileDownload.file_name, nomadnetFileDownload.file_bytes);
|
||||
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
||||
this.currentFileDownloadId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -353,6 +413,7 @@ export default {
|
||||
if(nomadnetFileDownload.status === "failure" && nomadnetFileDownloadCallback.onFailureCallback){
|
||||
nomadnetFileDownloadCallback.onFailureCallback(nomadnetFileDownload.failure_reason);
|
||||
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
||||
this.currentFileDownloadId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -365,6 +426,26 @@ export default {
|
||||
break;
|
||||
|
||||
}
|
||||
case 'nomadnet.download.cancelled': {
|
||||
// handle download cancellation
|
||||
const downloadId = json.download_id;
|
||||
|
||||
// clear page download if it matches
|
||||
if(this.currentPageDownloadId === downloadId){
|
||||
this.currentPageDownloadId = null;
|
||||
this.isLoadingNodePage = false;
|
||||
this.nodePageContent = "Download cancelled";
|
||||
}
|
||||
|
||||
// clear file download if it matches
|
||||
if(this.currentFileDownloadId === downloadId){
|
||||
this.currentFileDownloadId = null;
|
||||
this.isDownloadingNodeFile = false;
|
||||
this.nodeFileDownloadSpeed = null;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
onDestinationPathClick: function(path) {
|
||||
@@ -480,12 +561,17 @@ export default {
|
||||
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
|
||||
|
||||
// update current route
|
||||
this.$router.replace({
|
||||
name: "nomadnetwork",
|
||||
const routeName = this.isPopoutMode ? "nomadnetwork-popout" : "nomadnetwork";
|
||||
const routeOptions = {
|
||||
name: routeName,
|
||||
params: {
|
||||
destinationHash: destinationHash,
|
||||
},
|
||||
});
|
||||
};
|
||||
if(!this.isPopoutMode && this.$route?.query){
|
||||
routeOptions.query = { ...this.$route.query };
|
||||
}
|
||||
this.$router.replace(routeOptions);
|
||||
|
||||
// get new sequence for this page load
|
||||
const seq = ++this.nodePageRequestSequence;
|
||||
@@ -736,8 +822,9 @@ export default {
|
||||
if(url.startsWith("lxmf@")){
|
||||
const destinationHash = url.replace("lxmf@", "");
|
||||
if(destinationHash.length === 32){
|
||||
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
|
||||
await this.$router.push({
|
||||
name: "messages",
|
||||
name: routeName,
|
||||
params: {
|
||||
destinationHash: destinationHash,
|
||||
},
|
||||
@@ -936,10 +1023,18 @@ export default {
|
||||
// clear selected node
|
||||
this.selectedNode = null;
|
||||
|
||||
if(this.isPopoutMode){
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// update current route
|
||||
this.$router.replace({
|
||||
name: "nomadnetwork",
|
||||
});
|
||||
const routeName = this.isPopoutMode ? "nomadnetwork-popout" : "nomadnetwork";
|
||||
const routeOptions = { name: routeName };
|
||||
if(!this.isPopoutMode && this.$route?.query){
|
||||
routeOptions.query = { ...this.$route.query };
|
||||
}
|
||||
this.$router.replace(routeOptions);
|
||||
|
||||
},
|
||||
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
|
||||
@@ -984,6 +1079,11 @@ export default {
|
||||
DialogUtils.alert(e.response?.data?.message ?? "Failed to identify!");
|
||||
}
|
||||
},
|
||||
getHashPopoutValue() {
|
||||
const hash = window.location.hash || "";
|
||||
const match = hash.match(/popout=([^&]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
|
||||
try {
|
||||
|
||||
@@ -1034,6 +1134,22 @@ export default {
|
||||
renderedNodePageContent() {
|
||||
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
|
||||
},
|
||||
cancelPageDownload() {
|
||||
if(this.currentPageDownloadId !== null){
|
||||
WebSocketConnection.send(JSON.stringify({
|
||||
"type": "nomadnet.download.cancel",
|
||||
"download_id": this.currentPageDownloadId,
|
||||
}));
|
||||
}
|
||||
},
|
||||
cancelFileDownload() {
|
||||
if(this.currentFileDownloadId !== null){
|
||||
WebSocketConnection.send(JSON.stringify({
|
||||
"type": "nomadnet.download.cancel",
|
||||
"download_id": this.currentFileDownloadId,
|
||||
}));
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-80 min-w-80 min-h-0 bg-white/90 dark:bg-zinc-950/80 backdrop-blur border-r border-gray-200 dark:border-zinc-800">
|
||||
|
||||
<div class="flex">
|
||||
<button @click="tab = 'favourites'" type="button" class="sidebar-tab" :class="{ 'sidebar-tab--active': tab === 'favourites' }">
|
||||
Favourites
|
||||
</button>
|
||||
<button @click="tab = 'announces'" type="button" class="sidebar-tab" :class="{ 'sidebar-tab--active': tab === 'announces' }">
|
||||
Announces
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col min-h-0">
|
||||
<div class="p-3 border-b border-gray-200 dark:border-zinc-800">
|
||||
<input v-model="favouritesSearchTerm" type="text" :placeholder="`Search ${favourites.length} favourites...`" class="input-field"/>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-2 pb-4">
|
||||
<div v-if="searchedFavourites.length > 0" class="space-y-2 pt-2">
|
||||
<div
|
||||
v-for="favourite of searchedFavourites"
|
||||
:key="favourite.destination_hash"
|
||||
@click="onFavouriteClick(favourite)"
|
||||
class="favourite-card"
|
||||
:class="[
|
||||
favourite.destination_hash === selectedDestinationHash ? 'favourite-card--active' : '',
|
||||
draggingFavouriteHash === favourite.destination_hash ? 'favourite-card--dragging' : ''
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="onFavouriteDragStart($event, favourite)"
|
||||
@dragover.prevent="onFavouriteDragOver($event)"
|
||||
@drop.prevent="onFavouriteDrop($event, favourite)"
|
||||
@dragend="onFavouriteDragEnd"
|
||||
>
|
||||
<div class="favourite-card__icon">
|
||||
<MaterialDesignIcon icon-name="server-network" class="w-5 h-5"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white truncate" :title="favourite.display_name">{{ favourite.display_name }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ formatDestinationHash(favourite.destination_hash) }}</div>
|
||||
</div>
|
||||
<DropDownMenu>
|
||||
<template #button>
|
||||
<IconButton class="bg-transparent dark:bg-transparent w-8 h-8 p-0 flex items-center justify-center">
|
||||
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5"/>
|
||||
</IconButton>
|
||||
</template>
|
||||
<template #items>
|
||||
<DropDownMenuItem @click="onRenameFavourite(favourite)">
|
||||
<MaterialDesignIcon icon-name="pencil" class="w-5 h-5"/>
|
||||
<span>Rename</span>
|
||||
</DropDownMenuItem>
|
||||
<DropDownMenuItem @click="onRemoveFavourite(favourite)">
|
||||
<MaterialDesignIcon icon-name="trash-can" class="w-5 h-5 text-red-500"/>
|
||||
<span class="text-red-500">Remove</span>
|
||||
</DropDownMenuItem>
|
||||
</template>
|
||||
</DropDownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<MaterialDesignIcon icon-name="star-outline" class="w-8 h-8"/>
|
||||
<div class="font-semibold">No favourites</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Add nodes from the announces tab.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 flex flex-col min-h-0">
|
||||
<div class="p-3 border-b border-gray-200 dark:border-zinc-800">
|
||||
<input v-model="nodesSearchTerm" type="text" placeholder="Search announces" class="input-field"/>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-2 pb-4">
|
||||
<div v-if="searchedNodes.length > 0" class="space-y-2 pt-2">
|
||||
<div v-for="node of searchedNodes" :key="node.destination_hash" @click="onNodeClick(node)" class="announce-card" :class="{ 'announce-card--active': node.destination_hash === selectedDestinationHash }">
|
||||
<div class="announce-card__icon">
|
||||
<MaterialDesignIcon icon-name="satellite-uplink" class="w-5 h-5"/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white truncate" :title="node.display_name">{{ node.display_name }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Announced {{ formatTimeAgo(node.updated_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<MaterialDesignIcon icon-name="radar" class="w-8 h-8"/>
|
||||
<div class="font-semibold">No announces yet</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Listening for peers on the mesh.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Utils from "../../js/Utils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import DropDownMenu from "../DropDownMenu.vue";
|
||||
import IconButton from "../IconButton.vue";
|
||||
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||
|
||||
export default {
|
||||
name: 'NomadNetworkSidebar',
|
||||
components: {DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon},
|
||||
props: {
|
||||
nodes: Object,
|
||||
favourites: Array,
|
||||
selectedDestinationHash: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tab: "favourites",
|
||||
favouritesSearchTerm: "",
|
||||
nodesSearchTerm: "",
|
||||
favouritesOrder: [],
|
||||
draggingFavouriteHash: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadFavouriteOrder();
|
||||
this.ensureFavouriteOrder();
|
||||
},
|
||||
watch: {
|
||||
favourites: {
|
||||
handler() {
|
||||
this.ensureFavouriteOrder();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onNodeClick(node) {
|
||||
this.$emit("node-click", node);
|
||||
},
|
||||
onFavouriteClick(favourite) {
|
||||
this.onNodeClick(favourite);
|
||||
},
|
||||
onRenameFavourite(favourite) {
|
||||
this.$emit("rename-favourite", favourite);
|
||||
},
|
||||
onRemoveFavourite(favourite) {
|
||||
this.$emit("remove-favourite", favourite);
|
||||
},
|
||||
loadFavouriteOrder() {
|
||||
try {
|
||||
const stored = localStorage.getItem("meshchat.nomadnet.favourites");
|
||||
if(stored){
|
||||
this.favouritesOrder = JSON.parse(stored);
|
||||
}
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
persistFavouriteOrder() {
|
||||
localStorage.setItem("meshchat.nomadnet.favourites", JSON.stringify(this.favouritesOrder));
|
||||
},
|
||||
ensureFavouriteOrder() {
|
||||
const hashes = this.favourites.map((fav) => fav.destination_hash);
|
||||
const nextOrder = this.favouritesOrder.filter((hash) => hashes.includes(hash));
|
||||
hashes.forEach((hash) => {
|
||||
if(!nextOrder.includes(hash)){
|
||||
nextOrder.push(hash);
|
||||
}
|
||||
});
|
||||
if(JSON.stringify(nextOrder) !== JSON.stringify(this.favouritesOrder)){
|
||||
this.favouritesOrder = nextOrder;
|
||||
this.persistFavouriteOrder();
|
||||
}
|
||||
},
|
||||
onFavouriteDragStart(event, favourite) {
|
||||
try {
|
||||
if(event?.dataTransfer){
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", favourite.destination_hash);
|
||||
}
|
||||
} catch(e) {
|
||||
// ignore for browsers that prevent setting drag meta
|
||||
}
|
||||
this.draggingFavouriteHash = favourite.destination_hash;
|
||||
},
|
||||
onFavouriteDragOver(event) {
|
||||
if(event?.dataTransfer){
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
},
|
||||
onFavouriteDrop(event, targetFavourite) {
|
||||
if(!this.draggingFavouriteHash || this.draggingFavouriteHash === targetFavourite.destination_hash){
|
||||
return;
|
||||
}
|
||||
const fromIndex = this.favouritesOrder.indexOf(this.draggingFavouriteHash);
|
||||
const toIndex = this.favouritesOrder.indexOf(targetFavourite.destination_hash);
|
||||
if(fromIndex === -1 || toIndex === -1){
|
||||
return;
|
||||
}
|
||||
const updated = [...this.favouritesOrder];
|
||||
updated.splice(fromIndex, 1);
|
||||
updated.splice(toIndex, 0, this.draggingFavouriteHash);
|
||||
this.favouritesOrder = updated;
|
||||
this.persistFavouriteOrder();
|
||||
this.draggingFavouriteHash = null;
|
||||
},
|
||||
onFavouriteDragEnd() {
|
||||
this.draggingFavouriteHash = null;
|
||||
},
|
||||
formatTimeAgo: function(datetimeString) {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
},
|
||||
formatDestinationHash: function(destinationHash) {
|
||||
return Utils.formatDestinationHash(destinationHash);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
nodesCount() {
|
||||
return Object.keys(this.nodes).length;
|
||||
},
|
||||
nodesOrderedByLatestAnnounce() {
|
||||
const nodes = Object.values(this.nodes);
|
||||
return nodes.sort(function(nodeA, nodeB) {
|
||||
// order by updated_at desc
|
||||
const nodeAUpdatedAt = new Date(nodeA.updated_at).getTime();
|
||||
const nodeBUpdatedAt = new Date(nodeB.updated_at).getTime();
|
||||
return nodeBUpdatedAt - nodeAUpdatedAt;
|
||||
});
|
||||
},
|
||||
searchedNodes() {
|
||||
return this.nodesOrderedByLatestAnnounce.filter((node) => {
|
||||
const search = this.nodesSearchTerm.toLowerCase();
|
||||
const matchesDisplayName = node.display_name.toLowerCase().includes(search);
|
||||
const matchesDestinationHash = node.destination_hash.toLowerCase().includes(search);
|
||||
return matchesDisplayName || matchesDestinationHash;
|
||||
});
|
||||
},
|
||||
orderedFavourites() {
|
||||
return [...this.favourites].sort((a, b) => {
|
||||
return this.favouritesOrder.indexOf(a.destination_hash) - this.favouritesOrder.indexOf(b.destination_hash);
|
||||
});
|
||||
},
|
||||
searchedFavourites() {
|
||||
return this.orderedFavourites.filter((favourite) => {
|
||||
const search = this.favouritesSearchTerm.toLowerCase();
|
||||
const matchesDisplayName = favourite.display_name.toLowerCase().includes(search);
|
||||
const matchesCustomDisplayName = favourite.custom_display_name?.toLowerCase()?.includes(search) === true;
|
||||
const matchesDestinationHash = favourite.destination_hash.toLowerCase().includes(search);
|
||||
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-tab {
|
||||
@apply w-1/2 py-3 text-sm font-semibold text-gray-500 dark:text-gray-400 border-b-2 border-transparent transition;
|
||||
}
|
||||
.sidebar-tab--active {
|
||||
@apply text-blue-600 border-blue-500 dark:text-blue-300 dark:border-blue-400;
|
||||
}
|
||||
.favourite-card {
|
||||
@apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500;
|
||||
}
|
||||
.favourite-card--active {
|
||||
@apply border-blue-500 dark:border-blue-400 bg-blue-50/60 dark:bg-blue-900/30;
|
||||
}
|
||||
.favourite-card__icon,
|
||||
.announce-card__icon {
|
||||
@apply w-10 h-10 rounded-xl bg-gray-100 dark:bg-zinc-800 flex items-center justify-center text-gray-500 dark:text-gray-300;
|
||||
}
|
||||
.favourite-card--dragging {
|
||||
@apply opacity-60 ring-2 ring-blue-300 dark:ring-blue-600;
|
||||
}
|
||||
.announce-card {
|
||||
@apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500;
|
||||
}
|
||||
.announce-card--active {
|
||||
@apply border-blue-500 dark:border-blue-400 bg-blue-50/70 dark:bg-blue-900/30;
|
||||
}
|
||||
.empty-state {
|
||||
@apply flex flex-col items-center justify-center text-center gap-2 text-gray-500 dark:text-gray-400 mt-20;
|
||||
}
|
||||
</style>
|
||||
@@ -1,59 +1,85 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="flex flex-col h-full space-y-2 p-2 overflow-y-auto">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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-4xl mx-auto">
|
||||
|
||||
<!-- appearance -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Ping</div>
|
||||
<div class="dark:divide-zinc-700 text-gray-900 dark:text-gray-100 p-2">
|
||||
Only lxmf.delivery destinations can be pinged.
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card space-y-5">
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Diagnostics</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">Ping Mesh Peers</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Only <code class="font-mono text-xs">lxmf.delivery</code> destinations respond to ping.</div>
|
||||
</div>
|
||||
|
||||
<!-- inputs -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Destination Hash</div>
|
||||
<div class="flex">
|
||||
<input v-model="destinationHash" type="text" placeholder="e.g: 7b746057a7294469799cd8d7d429676a" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="glass-label">Destination Hash</label>
|
||||
<input v-model="destinationHash" type="text" placeholder="e.g. 7b746057a7294469799cd8d7d429676a" class="input-field font-mono"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="glass-label">Ping Timeout (seconds)</label>
|
||||
<input v-model="timeout" type="number" min="1" class="input-field"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Ping Timeout (seconds)</div>
|
||||
<div class="flex">
|
||||
<input v-model="timeout" type="number" placeholder="Timeout" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2 space-x-1">
|
||||
<button v-if="!isRunning" @click="start" 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-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
|
||||
Start
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button v-if="!isRunning" @click="start" type="button" class="primary-chip px-4 py-2 text-sm">
|
||||
<MaterialDesignIcon icon-name="play" class="w-4 h-4"/>
|
||||
Start Ping
|
||||
</button>
|
||||
<button v-if="isRunning" @click="stop" 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-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
|
||||
<button v-else @click="stop" type="button" class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50">
|
||||
<MaterialDesignIcon icon-name="pause" class="w-4 h-4"/>
|
||||
Stop
|
||||
</button>
|
||||
<button @click="clear" 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-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
|
||||
<button @click="clear" type="button" class="secondary-chip px-4 py-2 text-sm">
|
||||
<MaterialDesignIcon icon-name="broom" class="w-4 h-4"/>
|
||||
Clear Results
|
||||
</button>
|
||||
<button @click="dropPath" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2 py-1 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">
|
||||
<button @click="dropPath" type="button" class="inline-flex items-center gap-2 rounded-full bg-red-600/90 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-500 transition">
|
||||
<MaterialDesignIcon icon-name="link-variant-remove" class="w-4 h-4"/>
|
||||
Drop Path
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-xs font-semibold">
|
||||
<span :class="[isRunning ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200' : 'bg-gray-200 text-gray-700 dark:bg-zinc-800 dark:text-gray-200', 'rounded-full px-3 py-1']">
|
||||
Status: {{ isRunning ? 'Running' : 'Idle' }}
|
||||
</span>
|
||||
<span v-if="lastPingSummary?.duration" class="rounded-full px-3 py-1 bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
Last RTT: {{ lastPingSummary.duration }}
|
||||
</span>
|
||||
<span v-if="lastPingSummary?.error" class="rounded-full px-3 py-1 bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200">
|
||||
Last Error: {{ lastPingSummary.error }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- results -->
|
||||
<div class="flex flex-col h-full bg-white dark:bg-zinc-800 rounded shadow overflow-hidden min-h-52">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Results</div>
|
||||
<div id="results" class="flex flex-col h-full bg-black text-white dark:bg-zinc-800 dark:text-gray-200 p-2 overflow-y-auto overflow-x-auto font-mono whitespace-nowrap">
|
||||
<div v-for="pingResult of pingResults" class="w-fit">{{ pingResult }}</div>
|
||||
<div class="glass-card flex flex-col min-h-[320px] space-y-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Console Output</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Streaming seq responses in real time</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
seq #{{ seq }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="lastPingSummary && !lastPingSummary.error" class="flex flex-wrap gap-2 text-xs text-gray-700 dark:text-gray-200">
|
||||
<span v-if="lastPingSummary.hopsThere != null" class="stat-chip">Hops there: {{ lastPingSummary.hopsThere }}</span>
|
||||
<span v-if="lastPingSummary.hopsBack != null" class="stat-chip">Hops back: {{ lastPingSummary.hopsBack }}</span>
|
||||
<span v-if="lastPingSummary.rssi != null" class="stat-chip">RSSI {{ lastPingSummary.rssi }} dBm</span>
|
||||
<span v-if="lastPingSummary.snr != null" class="stat-chip">SNR {{ lastPingSummary.snr }} dB</span>
|
||||
<span v-if="lastPingSummary.quality != null" class="stat-chip">Quality {{ lastPingSummary.quality }}%</span>
|
||||
<span v-if="lastPingSummary.via" class="stat-chip">Interface {{ lastPingSummary.via }}</span>
|
||||
</div>
|
||||
|
||||
<div id="results" class="flex-1 overflow-y-auto rounded-2xl bg-black/80 text-emerald-300 font-mono text-xs p-3 space-y-1 shadow-inner border border-zinc-900">
|
||||
<div v-if="pingResults.length === 0" class="text-emerald-500/80">No pings yet. Start a run to collect RTT data.</div>
|
||||
<div v-for="(pingResult, index) in pingResults" :key="`${index}-${pingResult}`" class="whitespace-pre-wrap">{{ pingResult }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,9 +87,13 @@
|
||||
<script>
|
||||
import {CanceledError} from "axios";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: 'PingPage',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isRunning: false,
|
||||
@@ -72,6 +102,7 @@ export default {
|
||||
seq: 0,
|
||||
pingResults: [],
|
||||
abortController: null,
|
||||
lastPingSummary: null,
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
@@ -116,10 +147,13 @@ export default {
|
||||
},
|
||||
async stop() {
|
||||
this.isRunning = false;
|
||||
this.abortController.abort();
|
||||
if(this.abortController){
|
||||
this.abortController.abort();
|
||||
}
|
||||
},
|
||||
async clear() {
|
||||
this.pingResults = [];
|
||||
this.lastPingSummary = null;
|
||||
},
|
||||
async sleep(millis) {
|
||||
return new Promise((resolve, reject) => setTimeout(resolve, millis));
|
||||
@@ -168,6 +202,15 @@ export default {
|
||||
|
||||
// update ui
|
||||
this.addPingResult(info.join(" "));
|
||||
this.lastPingSummary = {
|
||||
duration: rttDurationString,
|
||||
hopsThere: pingResult.hops_there,
|
||||
hopsBack: pingResult.hops_back,
|
||||
rssi: pingResult.rssi,
|
||||
snr: pingResult.snr,
|
||||
quality: pingResult.quality,
|
||||
via: pingResult.receiving_interface,
|
||||
};
|
||||
|
||||
} catch(e) {
|
||||
|
||||
@@ -181,6 +224,9 @@ export default {
|
||||
// add ping error to results
|
||||
const message = e.response?.data?.message ?? e;
|
||||
this.addPingResult(`seq=${this.seq} error=${message}`);
|
||||
this.lastPingSummary = {
|
||||
error: typeof message === "string" ? message : JSON.stringify(message),
|
||||
};
|
||||
|
||||
}
|
||||
},
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gray-50 dark:bg-zinc-950">
|
||||
|
||||
<!-- search and sort -->
|
||||
<div v-if="propagationNodes.length > 0" class="flex flex-col sm:flex-row gap-2 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 px-4 py-3">
|
||||
<input v-model="searchTerm" type="text" :placeholder="`Search ${propagationNodes.length} Propagation Nodes...`" class="flex-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500">
|
||||
<select v-model="sortBy" class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2 shadow-sm transition-all min-w-[180px]">
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="name-desc">Sort by Name (Z-A)</option>
|
||||
<option value="recent">Sort by Recent</option>
|
||||
<option value="oldest">Sort by Oldest</option>
|
||||
<option value="preferred">Preferred First</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- propagation nodes -->
|
||||
<div class="h-full overflow-y-auto px-4 py-4">
|
||||
<div v-if="paginatedNodes.length > 0" class="space-y-3 w-full">
|
||||
<div v-for="propagationNode of paginatedNodes" :key="propagationNode.destination_hash" class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-sm hover:shadow-md transition-shadow overflow-hidden" :class="{ 'ring-2 ring-blue-500 dark:ring-blue-400': config.lxmf_preferred_propagation_node_destination_hash === propagationNode.destination_hash }">
|
||||
<div class="p-4 flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="font-semibold text-gray-900 dark:text-zinc-100 truncate">{{ propagationNode.operator_display_name ?? "Unknown Operator" }}</div>
|
||||
<span v-if="config.lxmf_preferred_propagation_node_destination_hash === propagationNode.destination_hash" class="inline-flex items-center gap-1 rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs font-semibold text-blue-700 dark:text-blue-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Preferred
|
||||
</span>
|
||||
<span v-if="propagationNode.is_propagation_enabled === false" class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2 py-0.5 text-xs font-semibold text-red-700 dark:text-red-300">
|
||||
Disabled
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-zinc-400 font-mono truncate"><{{ propagationNode.destination_hash }}></div>
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-500 mt-1">Announced {{ formatTimeAgo(propagationNode.updated_at) }}</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<button v-if="config.lxmf_preferred_propagation_node_destination_hash === propagationNode.destination_hash" @click="stopUsingPropagationNode" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
Stop Using
|
||||
</button>
|
||||
<button v-else @click="usePropagationNode(propagationNode.destination_hash)" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
Set as Preferred
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- pagination -->
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between mt-6 pt-4 border-t border-gray-200 dark:border-zinc-800">
|
||||
<div class="text-sm text-gray-600 dark:text-zinc-400">
|
||||
Showing {{ startIndex + 1 }}-{{ endIndex }} of {{ sortedAndSearchedPropagationNodes.length }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="currentPage = Math.max(1, currentPage - 1)" :disabled="currentPage === 1" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed px-3 py-2 text-sm font-medium text-gray-700 dark:text-zinc-300 shadow-sm transition-colors">
|
||||
<svg 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="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button v-for="page in visiblePages" :key="page" @click="currentPage = page" type="button" :class="[ page === currentPage ? 'bg-blue-600 text-white dark:bg-blue-600' : 'bg-white dark:bg-zinc-900 text-gray-700 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-800' ]" class="w-10 h-10 rounded-xl border border-gray-200 dark:border-zinc-800 text-sm font-medium shadow-sm transition-colors">
|
||||
{{ page }}
|
||||
</button>
|
||||
</div>
|
||||
<button @click="currentPage = Math.min(totalPages, currentPage + 1)" :disabled="currentPage === totalPages" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed px-3 py-2 text-sm font-medium text-gray-700 dark:text-zinc-300 shadow-sm transition-colors">
|
||||
Next
|
||||
<svg 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="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="sortedAndSearchedPropagationNodes.length === 0" class="flex h-full">
|
||||
<div class="mx-auto my-auto text-center leading-5 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<!-- no propagation nodes at all -->
|
||||
<div v-if="propagationNodes.length === 0" class="flex flex-col">
|
||||
<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 Propagation Nodes</div>
|
||||
<div>Check back later, once someone has announced.</div>
|
||||
<div class="mt-4">
|
||||
<button @click="loadPropagationNodes" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- is searching, but no results -->
|
||||
<div v-if="searchTerm !== '' && propagationNodes.length > 0" class="flex flex-col">
|
||||
<div class="mx-auto mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold">No Search Results</div>
|
||||
<div>Your search didn't match any Propagation Nodes!</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Utils from "../../js/Utils";
|
||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
|
||||
export default {
|
||||
name: 'PropagationNodesPage',
|
||||
data() {
|
||||
return {
|
||||
searchTerm: "",
|
||||
sortBy: "preferred",
|
||||
propagationNodes: [],
|
||||
config: {
|
||||
lxmf_preferred_propagation_node_destination_hash: null,
|
||||
},
|
||||
currentPage: 1,
|
||||
itemsPerPage: 20,
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
|
||||
},
|
||||
mounted() {
|
||||
|
||||
// listen for websocket messages
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
|
||||
this.getConfig();
|
||||
this.loadPropagationNodes();
|
||||
|
||||
},
|
||||
methods: {
|
||||
async onWebsocketMessage(message) {
|
||||
const json = JSON.parse(message.data);
|
||||
switch(json.type){
|
||||
case 'config': {
|
||||
this.config = json.config;
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
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 updateConfig(config) {
|
||||
try {
|
||||
const response = await window.axios.patch("/api/v1/config", config);
|
||||
this.config = response.data.config;
|
||||
} catch(e) {
|
||||
alert("Failed to save config!");
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async loadPropagationNodes() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/lxmf/propagation-nodes`, {
|
||||
params: {
|
||||
limit: 500,
|
||||
},
|
||||
});
|
||||
this.propagationNodes = response.data.lxmf_propagation_nodes;
|
||||
} catch(e) {
|
||||
// do nothing if failed to load
|
||||
}
|
||||
},
|
||||
async usePropagationNode(destination_hash) {
|
||||
await this.updateConfig({
|
||||
lxmf_preferred_propagation_node_destination_hash: destination_hash,
|
||||
});
|
||||
},
|
||||
async stopUsingPropagationNode() {
|
||||
await this.updateConfig({
|
||||
lxmf_preferred_propagation_node_destination_hash: null,
|
||||
});
|
||||
},
|
||||
formatTimeAgo: function(datetimeString) {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
searchTerm() {
|
||||
this.currentPage = 1;
|
||||
},
|
||||
sortBy() {
|
||||
this.currentPage = 1;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
searchedPropagationNodes() {
|
||||
return this.propagationNodes.filter((propagationNode) => {
|
||||
const search = this.searchTerm.toLowerCase();
|
||||
const matchesOperatorDisplayName = propagationNode.operator_display_name?.toLowerCase()?.includes(search) ?? false;
|
||||
const matchesDestinationHash = propagationNode.destination_hash.toLowerCase().includes(search);
|
||||
return matchesOperatorDisplayName || matchesDestinationHash;
|
||||
});
|
||||
},
|
||||
sortedAndSearchedPropagationNodes() {
|
||||
let nodes = [...this.searchedPropagationNodes];
|
||||
|
||||
switch(this.sortBy) {
|
||||
case "name":
|
||||
nodes.sort((a, b) => {
|
||||
const nameA = (a.operator_display_name ?? "Unknown Operator").toLowerCase();
|
||||
const nameB = (b.operator_display_name ?? "Unknown Operator").toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
break;
|
||||
case "name-desc":
|
||||
nodes.sort((a, b) => {
|
||||
const nameA = (a.operator_display_name ?? "Unknown Operator").toLowerCase();
|
||||
const nameB = (b.operator_display_name ?? "Unknown Operator").toLowerCase();
|
||||
return nameB.localeCompare(nameA);
|
||||
});
|
||||
break;
|
||||
case "recent":
|
||||
nodes.sort((a, b) => {
|
||||
const timeA = new Date(a.updated_at).getTime();
|
||||
const timeB = new Date(b.updated_at).getTime();
|
||||
return timeB - timeA;
|
||||
});
|
||||
break;
|
||||
case "oldest":
|
||||
nodes.sort((a, b) => {
|
||||
const timeA = new Date(a.updated_at).getTime();
|
||||
const timeB = new Date(b.updated_at).getTime();
|
||||
return timeA - timeB;
|
||||
});
|
||||
break;
|
||||
case "preferred":
|
||||
default:
|
||||
nodes.sort((a, b) => {
|
||||
const aIsPreferred = this.config.lxmf_preferred_propagation_node_destination_hash === a.destination_hash;
|
||||
const bIsPreferred = this.config.lxmf_preferred_propagation_node_destination_hash === b.destination_hash;
|
||||
if(aIsPreferred && !bIsPreferred) return -1;
|
||||
if(!aIsPreferred && bIsPreferred) return 1;
|
||||
const timeA = new Date(a.updated_at).getTime();
|
||||
const timeB = new Date(b.updated_at).getTime();
|
||||
return timeB - timeA;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return nodes;
|
||||
},
|
||||
totalPages() {
|
||||
return Math.ceil(this.sortedAndSearchedPropagationNodes.length / this.itemsPerPage);
|
||||
},
|
||||
startIndex() {
|
||||
return (this.currentPage - 1) * this.itemsPerPage;
|
||||
},
|
||||
endIndex() {
|
||||
return Math.min(this.startIndex + this.itemsPerPage, this.sortedAndSearchedPropagationNodes.length);
|
||||
},
|
||||
paginatedNodes() {
|
||||
return this.sortedAndSearchedPropagationNodes.slice(this.startIndex, this.endIndex);
|
||||
},
|
||||
visiblePages() {
|
||||
const pages = [];
|
||||
const maxVisible = 5;
|
||||
let start = Math.max(1, this.currentPage - Math.floor(maxVisible / 2));
|
||||
let end = Math.min(this.totalPages, start + maxVisible - 1);
|
||||
if(end - start < maxVisible - 1) {
|
||||
start = Math.max(1, end - maxVisible + 1);
|
||||
}
|
||||
for(let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
472
meshchatx/src/frontend/components/settings/SettingsPage.vue
Normal file
@@ -0,0 +1,472 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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">
|
||||
|
||||
<!-- hero card -->
|
||||
<div class="bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl p-5 md:p-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-4">
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Profile</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ config.display_name }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Manage your identity, transport participation and LXMF defaults.</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<button @click="copyValue(config.identity_hash, 'Identity Hash')" type="button" class="inline-flex items-center justify-center gap-x-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-zinc-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-400/70 transition">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||
Identity
|
||||
</button>
|
||||
<button @click="copyValue(config.lxmf_address_hash, 'LXMF Address')" type="button" class="inline-flex items-center justify-center gap-x-2 rounded-xl bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 px-4 py-2 text-sm font-semibold text-white shadow hover:shadow-md transition">
|
||||
<MaterialDesignIcon icon-name="account-plus" class="w-4 h-4"/>
|
||||
LXMF Address
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="copyToast" class="mt-3 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200 px-3 py-1 text-xs inline-flex items-center gap-2">
|
||||
{{ copyToast }}
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-ping"></span>
|
||||
</div>
|
||||
</transition>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70">
|
||||
<div class="text-xs uppercase tracking-wide">Theme</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white capitalize">{{ config.theme }} mode</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70">
|
||||
<div class="text-xs uppercase tracking-wide">Transport</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white">{{ config.is_transport_enabled ? 'Enabled' : 'Disabled' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70">
|
||||
<div class="text-xs uppercase tracking-wide">Propagation</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white">{{ config.lxmf_local_propagation_node_enabled ? 'Local node running' : 'Client-only' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 mt-4 text-sm text-gray-700 dark:text-gray-200 sm:grid-cols-2">
|
||||
<div class="address-card">
|
||||
<div class="address-card__label">Identity Hash</div>
|
||||
<div class="address-card__value monospace-field">{{ config.identity_hash }}</div>
|
||||
<button @click="copyValue(config.identity_hash, 'Identity Hash')" type="button" class="address-card__action">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="address-card">
|
||||
<div class="address-card__label">LXMF Address</div>
|
||||
<div class="address-card__value monospace-field">{{ config.lxmf_address_hash }}</div>
|
||||
<button @click="copyValue(config.lxmf_address_hash, 'LXMF Address')" type="button" class="address-card__action">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- settings grid -->
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
|
||||
<!-- Appearance -->
|
||||
<section class="glass-card">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Personalise</div>
|
||||
<h2>Appearance</h2>
|
||||
<p>Switch between light and dark presets anytime.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-3">
|
||||
<select v-model="config.theme" @change="onThemeChange" class="input-field">
|
||||
<option value="light">Light Theme</option>
|
||||
<option value="dark">Dark Theme</option>
|
||||
</select>
|
||||
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-300 border border-dashed border-gray-200 dark:border-zinc-800 rounded-2xl px-3 py-2">
|
||||
<div>Live preview updates instantly.</div>
|
||||
<span class="inline-flex items-center gap-1 text-blue-500 dark:text-blue-300 text-xs font-semibold uppercase">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
|
||||
Realtime
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Transport -->
|
||||
<section class="glass-card">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Reticulum</div>
|
||||
<h2>Transport Mode</h2>
|
||||
<p>Relay paths and traffic for nearby peers.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-3">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.is_transport_enabled" @change="onIsTransportEnabledChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Enable Transport Mode</span>
|
||||
<span class="setting-toggle__description">Route announces, respond to path requests and help your mesh stay online.</span>
|
||||
<span class="setting-toggle__hint">Requires restart after toggling.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Interfaces -->
|
||||
<section class="glass-card">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Adapters</div>
|
||||
<h2>Interfaces</h2>
|
||||
<p>Show curated community configs inside the interface wizard.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-3">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.show_suggested_community_interfaces" @change="onShowSuggestedCommunityInterfacesChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Show Community Interfaces</span>
|
||||
<span class="setting-toggle__description">Surface community-maintained presets while adding new interfaces.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Messages -->
|
||||
<section class="glass-card">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Reliability</div>
|
||||
<h2>Messages</h2>
|
||||
<p>Control how MeshChat retries or escalates failed deliveries.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-3">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.auto_resend_failed_messages_when_announce_received" @change="onAutoResendFailedMessagesWhenAnnounceReceivedChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Auto resend when peer announces</span>
|
||||
<span class="setting-toggle__description">Failed messages automatically retry once the destination broadcasts again.</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.allow_auto_resending_failed_messages_with_attachments" @change="onAllowAutoResendingFailedMessagesWithAttachmentsChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Allow retries with attachments</span>
|
||||
<span class="setting-toggle__description">Large payloads will also be retried (useful when both peers have high limits).</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.auto_send_failed_messages_to_propagation_node" @change="onAutoSendFailedMessagesToPropagationNodeChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Auto fall back to propagation node</span>
|
||||
<span class="setting-toggle__description">Failed direct deliveries are queued on your preferred propagation node.</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Inbound Message Stamp Cost</div>
|
||||
<input v-model.number="config.lxmf_inbound_stamp_cost" @input="onLxmfInboundStampCostChange" type="number" min="1" max="254" placeholder="8" class="input-field">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Require proof-of-work stamps for direct delivery messages sent to you. Higher values require more computational work from senders. Range: 1-254. Default: 8.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Propagation nodes -->
|
||||
<section class="glass-card lg:col-span-2">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">LXMF</div>
|
||||
<h2>Propagation Nodes</h2>
|
||||
<p>Keep conversations flowing even when peers are offline.</p>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'propagation-nodes' }" class="primary-chip">
|
||||
Browse Nodes
|
||||
</RouterLink>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-5">
|
||||
<div class="info-callout">
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>Propagation nodes hold messages securely until recipients sync again.</li>
|
||||
<li>Nodes peer with each other to distribute encrypted payloads.</li>
|
||||
<li>Most nodes retain data ~30 days, then discard undelivered items.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.lxmf_local_propagation_node_enabled" @change="onLxmfLocalPropagationNodeEnabledChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Run a local propagation node</span>
|
||||
<span class="setting-toggle__description">MeshChat will announce and maintain a node using this local destination hash.</span>
|
||||
<span class="setting-toggle__hint monospace-field">{{ config.lxmf_local_propagation_node_address_hash || '—' }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Preferred Propagation Node</div>
|
||||
<input v-model="config.lxmf_preferred_propagation_node_destination_hash" @input="onLxmfPreferredPropagationNodeDestinationHashChange" type="text" placeholder="Destination hash, e.g. a39610c89d18bb48c73e429582423c24" class="input-field monospace-field">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Messages fallback to this node whenever direct delivery fails.</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Auto Sync Interval</div>
|
||||
<select v-model="config.lxmf_preferred_propagation_node_auto_sync_interval_seconds" @change="onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange" class="input-field">
|
||||
<option value="0">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-xs text-gray-600 dark:text-gray-400">
|
||||
<span v-if="config.lxmf_preferred_propagation_node_last_synced_at">Last synced {{ formatSecondsAgo(config.lxmf_preferred_propagation_node_last_synced_at) }} ago.</span>
|
||||
<span v-else>Last synced: never.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="config.lxmf_local_propagation_node_enabled" class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Propagation Node Stamp Cost</div>
|
||||
<input v-model.number="config.lxmf_propagation_node_stamp_cost" @input="onLxmfPropagationNodeStampCostChange" type="number" min="13" max="254" placeholder="16" class="input-field">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Require proof-of-work stamps for messages propagated through your node. Higher values require more computational work. Range: 13-254. Default: 16. <strong>Note:</strong> Changing this requires restarting the app.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Utils from "../../js/Utils";
|
||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: 'SettingsPage',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config: {
|
||||
auto_resend_failed_messages_when_announce_received: null,
|
||||
allow_auto_resending_failed_messages_with_attachments: null,
|
||||
auto_send_failed_messages_to_propagation_node: null,
|
||||
show_suggested_community_interfaces: null,
|
||||
lxmf_local_propagation_node_enabled: null,
|
||||
lxmf_preferred_propagation_node_destination_hash: null,
|
||||
},
|
||||
copyToast: null,
|
||||
copyToastTimeout: null,
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
clearTimeout(this.copyToastTimeout);
|
||||
|
||||
},
|
||||
mounted() {
|
||||
|
||||
// listen for websocket messages
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
|
||||
this.getConfig();
|
||||
|
||||
},
|
||||
methods: {
|
||||
async onWebsocketMessage(message) {
|
||||
const json = JSON.parse(message.data);
|
||||
switch(json.type){
|
||||
case 'config': {
|
||||
this.config = json.config;
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
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 updateConfig(config) {
|
||||
try {
|
||||
const response = await window.axios.patch("/api/v1/config", config);
|
||||
this.config = response.data.config;
|
||||
} catch(e) {
|
||||
alert("Failed to save config!");
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async copyValue(value, label) {
|
||||
if(!value){
|
||||
DialogUtils.alert(`Nothing to copy for ${label}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
this.showCopyToast(`${label} copied to clipboard`);
|
||||
} catch(e) {
|
||||
DialogUtils.alert(`${label}: ${value}`);
|
||||
}
|
||||
},
|
||||
showCopyToast(message) {
|
||||
this.copyToast = message;
|
||||
clearTimeout(this.copyToastTimeout);
|
||||
this.copyToastTimeout = setTimeout(() => {
|
||||
this.copyToast = null;
|
||||
}, 2500);
|
||||
},
|
||||
async onThemeChange() {
|
||||
await this.updateConfig({
|
||||
"theme": this.config.theme,
|
||||
});
|
||||
},
|
||||
async onAutoResendFailedMessagesWhenAnnounceReceivedChange() {
|
||||
await this.updateConfig({
|
||||
"auto_resend_failed_messages_when_announce_received": this.config.auto_resend_failed_messages_when_announce_received,
|
||||
});
|
||||
},
|
||||
async onAllowAutoResendingFailedMessagesWithAttachmentsChange() {
|
||||
await this.updateConfig({
|
||||
"allow_auto_resending_failed_messages_with_attachments": this.config.allow_auto_resending_failed_messages_with_attachments,
|
||||
});
|
||||
},
|
||||
async onAutoSendFailedMessagesToPropagationNodeChange() {
|
||||
await this.updateConfig({
|
||||
"auto_send_failed_messages_to_propagation_node": this.config.auto_send_failed_messages_to_propagation_node,
|
||||
});
|
||||
},
|
||||
async onShowSuggestedCommunityInterfacesChange() {
|
||||
await this.updateConfig({
|
||||
"show_suggested_community_interfaces": this.config.show_suggested_community_interfaces,
|
||||
});
|
||||
},
|
||||
async onLxmfPreferredPropagationNodeDestinationHashChange() {
|
||||
await this.updateConfig({
|
||||
"lxmf_preferred_propagation_node_destination_hash": this.config.lxmf_preferred_propagation_node_destination_hash,
|
||||
});
|
||||
},
|
||||
async onLxmfLocalPropagationNodeEnabledChange() {
|
||||
await this.updateConfig({
|
||||
"lxmf_local_propagation_node_enabled": this.config.lxmf_local_propagation_node_enabled,
|
||||
});
|
||||
},
|
||||
async onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange() {
|
||||
await this.updateConfig({
|
||||
"lxmf_preferred_propagation_node_auto_sync_interval_seconds": this.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds,
|
||||
});
|
||||
},
|
||||
async onLxmfInboundStampCostChange() {
|
||||
await this.updateConfig({
|
||||
"lxmf_inbound_stamp_cost": this.config.lxmf_inbound_stamp_cost,
|
||||
});
|
||||
},
|
||||
async onLxmfPropagationNodeStampCostChange() {
|
||||
await this.updateConfig({
|
||||
"lxmf_propagation_node_stamp_cost": this.config.lxmf_propagation_node_stamp_cost,
|
||||
});
|
||||
},
|
||||
async onIsTransportEnabledChange() {
|
||||
if(this.config.is_transport_enabled){
|
||||
try {
|
||||
const response = await window.axios.post("/api/v1/reticulum/enable-transport");
|
||||
DialogUtils.alert(response.data.message);
|
||||
} catch(e) {
|
||||
DialogUtils.alert("Failed to enable transport mode!");
|
||||
console.log(e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const response = await window.axios.post("/api/v1/reticulum/disable-transport");
|
||||
DialogUtils.alert(response.data.message);
|
||||
} catch(e) {
|
||||
DialogUtils.alert("Failed to disable transport mode!");
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
formatSecondsAgo: function(seconds) {
|
||||
return Utils.formatSecondsAgo(seconds);
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.glass-card {
|
||||
@apply bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg flex flex-col;
|
||||
}
|
||||
.glass-card__header {
|
||||
@apply flex items-center justify-between gap-3 px-4 py-4 border-b border-gray-100/70 dark:border-zinc-800/80;
|
||||
}
|
||||
.glass-card__header h2 {
|
||||
@apply text-lg font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
.glass-card__header p {
|
||||
@apply text-sm text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
.glass-card__eyebrow {
|
||||
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
.glass-card__body {
|
||||
@apply px-4 py-4 text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
.input-field {
|
||||
@apply bg-gray-50/90 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-2xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-2.5 text-gray-900 dark:text-gray-100 transition;
|
||||
}
|
||||
.setting-toggle {
|
||||
@apply flex items-start gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 px-3 py-3;
|
||||
}
|
||||
.setting-toggle input[type="checkbox"] {
|
||||
@apply w-4 h-4 mt-1 rounded border-gray-300 dark:border-zinc-600 text-blue-600 focus:ring-blue-500;
|
||||
}
|
||||
.setting-toggle__label {
|
||||
@apply flex-1 flex flex-col gap-0.5;
|
||||
}
|
||||
.setting-toggle__title {
|
||||
@apply text-sm font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
.setting-toggle__description {
|
||||
@apply text-sm text-gray-600 dark:text-gray-300;
|
||||
}
|
||||
.setting-toggle__hint {
|
||||
@apply text-xs text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
.primary-chip {
|
||||
@apply inline-flex items-center gap-x-1 rounded-full bg-blue-600/90 px-4 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
|
||||
}
|
||||
.info-callout {
|
||||
@apply rounded-2xl border border-blue-100 dark:border-blue-900/40 bg-blue-50/60 dark:bg-blue-900/20 px-3 py-3 text-blue-900 dark:text-blue-100;
|
||||
}
|
||||
.monospace-field {
|
||||
font-family: "Roboto Mono", monospace;
|
||||
}
|
||||
.address-card {
|
||||
@apply relative border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white/80 dark:bg-zinc-900/70 p-4 space-y-2;
|
||||
}
|
||||
.address-card__label {
|
||||
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
.address-card__value {
|
||||
@apply text-sm text-gray-900 dark:text-white break-words pr-16;
|
||||
}
|
||||
.address-card__action {
|
||||
@apply absolute top-3 right-3 inline-flex items-center gap-1 rounded-full border border-gray-200 dark:border-zinc-700 px-3 py-1 text-xs font-semibold text-gray-700 dark:text-gray-100 bg-white/70 dark:bg-zinc-900/60 hover:border-blue-400 dark:hover:border-blue-500 transition;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
67
meshchatx/src/frontend/components/tools/ToolsPage.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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">Utilities</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">Power tools for operators</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Diagnostics and firmware helpers ship with MeshChat so you can troubleshoot peers without leaving the console.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<RouterLink :to="{ name: 'ping' }" class="tool-card glass-card">
|
||||
<div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
|
||||
<MaterialDesignIcon icon-name="radar" class="w-6 h-6"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">Ping</div>
|
||||
<div class="tool-card__description">Latency test for any LXMF destination hash with live status.</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron"/>
|
||||
</RouterLink>
|
||||
|
||||
<a target="_blank" href="/rnode-flasher/index.html" class="tool-card glass-card">
|
||||
<div class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200">
|
||||
<img src="/rnode-flasher/reticulum_logo_512.png" class="w-8 h-8 rounded-full" alt="RNode"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">RNode Flasher</div>
|
||||
<div class="tool-card__description">Flash and update RNode adapters without touching the command line.</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="open-in-new" class="tool-card__chevron"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: 'ToolsPage',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tool-card {
|
||||
@apply flex items-center gap-4 hover:border-blue-400 dark:hover:border-blue-500 transition cursor-pointer;
|
||||
}
|
||||
.tool-card__icon {
|
||||
@apply w-12 h-12 rounded-2xl flex items-center justify-center;
|
||||
}
|
||||
.tool-card__title {
|
||||
@apply text-lg font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
.tool-card__description {
|
||||
@apply text-sm text-gray-600 dark:text-gray-300;
|
||||
}
|
||||
.tool-card__chevron {
|
||||
@apply w-5 h-5 text-gray-400;
|
||||
}
|
||||
</style>
|
||||
@@ -9,14 +9,6 @@
|
||||
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
|
||||
<title>Reticulum MeshChat</title>
|
||||
|
||||
<!-- codec2 -->
|
||||
<script src="assets/js/codec2-emscripten/c2enc.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/c2dec.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/sox.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/codec2-lib.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/wav-encoder.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/codec2-microphone-recorder.js"></script>
|
||||
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div id="app"></div>
|
||||
62
meshchatx/src/frontend/js/Codec2Loader.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const codec2ScriptPaths = [
|
||||
'/assets/js/codec2-emscripten/c2enc.js',
|
||||
'/assets/js/codec2-emscripten/c2dec.js',
|
||||
'/assets/js/codec2-emscripten/sox.js',
|
||||
'/assets/js/codec2-emscripten/codec2-lib.js',
|
||||
'/assets/js/codec2-emscripten/wav-encoder.js',
|
||||
'/assets/js/codec2-emscripten/codec2-microphone-recorder.js',
|
||||
];
|
||||
|
||||
let loadPromise = null;
|
||||
|
||||
function injectScript(src) {
|
||||
if (typeof document === 'undefined') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const attrName = 'data-codec2-src';
|
||||
const loadedAttr = 'data-codec2-loaded';
|
||||
const existing = document.querySelector(`script[${attrName}="${src}"]`);
|
||||
|
||||
if (existing) {
|
||||
if (existing.getAttribute(loadedAttr) === 'true') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
existing.addEventListener('load', () => resolve(), { once: true });
|
||||
existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = false;
|
||||
script.setAttribute(attrName, src);
|
||||
script.addEventListener('load', () => {
|
||||
script.setAttribute(loadedAttr, 'true');
|
||||
resolve();
|
||||
});
|
||||
script.addEventListener('error', () => {
|
||||
script.remove();
|
||||
reject(new Error(`Failed to load ${src}`));
|
||||
});
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureCodec2ScriptsLoaded() {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (!loadPromise) {
|
||||
loadPromise = codec2ScriptPaths.reduce(
|
||||
(chain, src) => chain.then(() => injectScript(src)),
|
||||
Promise.resolve(),
|
||||
);
|
||||
}
|
||||
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
import vClickOutside from "click-outside-vue3";
|
||||
import "./style.css";
|
||||
import "./fonts/RobotoMonoNerdFont/font.css";
|
||||
import { ensureCodec2ScriptsLoaded } from "./js/Codec2Loader";
|
||||
|
||||
import App from './components/App.vue';
|
||||
|
||||
@@ -50,6 +51,13 @@ const router = createRouter({
|
||||
props: true,
|
||||
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "messages-popout",
|
||||
path: '/popout/messages/:destinationHash?',
|
||||
props: true,
|
||||
meta: { popoutType: "conversation", isPopout: true },
|
||||
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "network-visualiser",
|
||||
path: '/network-visualiser',
|
||||
@@ -61,6 +69,13 @@ const router = createRouter({
|
||||
props: true,
|
||||
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "nomadnetwork-popout",
|
||||
path: '/popout/nomadnetwork/:destinationHash?',
|
||||
props: true,
|
||||
meta: { popoutType: "nomad", isPopout: true },
|
||||
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "propagation-nodes",
|
||||
path: '/propagation-nodes',
|
||||
@@ -86,11 +101,21 @@ const router = createRouter({
|
||||
path: '/tools',
|
||||
component: defineAsyncComponent(() => import("./components/tools/ToolsPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "call",
|
||||
path: '/call',
|
||||
component: defineAsyncComponent(() => import("./components/call/CallPage.vue")),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(vuetify)
|
||||
.use(vClickOutside)
|
||||
.mount('#app');
|
||||
async function bootstrap() {
|
||||
await ensureCodec2ScriptsLoaded();
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(vuetify)
|
||||
.use(vClickOutside)
|
||||
.mount('#app');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |