Compare commits
129 Commits
actions-fu
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f15a67b8e | |||
|
|
675975bafc | ||
| 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
|
|||
|
0543bd6044
|
|||
|
17d25b2e0a
|
|||
|
1a4b99b201
|
|||
|
9115f6ecfa
|
|||
|
918fcb051c
|
|||
|
acbe3597d6
|
|||
|
3566c6b2da
|
|||
|
8b044f6dab
|
|||
|
4c4b963aef
|
|||
|
38ac972960
|
|||
|
becd3aa15d
|
|||
|
ac907308c0
|
|||
|
fa2fe6a15d
|
|||
|
927255f44c
|
|||
|
0318cb7e4a
|
|||
|
442ac41841
|
|||
|
40f286621d
|
|||
| 9944e9bd63 | |||
|
|
ec0b5a0924 | ||
| bae4e96d2a | |||
| fa15b8f7a3 | |||
| 2ee27557bd | |||
| 8b82a66315 | |||
| 72b0f95cf5 | |||
| 1f8ec5aa2f | |||
|
|
6827ae9c84 | ||
| 95ef0935da | |||
| 5a5d4b9283 | |||
| 51eaa83301 | |||
| b034b937cd | |||
| adac0e5bb1 | |||
| 12313d34ee | |||
| 55126eaf82 | |||
| 61ada872c0 | |||
|
|
3260bffd60 | ||
|
|
bbc1eec48e | ||
|
|
72266680a2 | ||
|
|
f0336873db | ||
|
|
d9a39f1ea9 | ||
| f0edb4bc8d |
10
.deepsource.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
version = 1
|
||||||
|
|
||||||
|
[[analyzers]]
|
||||||
|
name = "python"
|
||||||
|
|
||||||
|
[analyzers.meta]
|
||||||
|
runtime_version = "3.x.x"
|
||||||
|
|
||||||
|
[[analyzers]]
|
||||||
|
name = "docker"
|
||||||
77
.dockerignore
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
|
donate.md
|
||||||
|
screenshots/
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.github/
|
||||||
|
electron/
|
||||||
|
scripts/
|
||||||
|
Makefile
|
||||||
|
|
||||||
|
# Build artifacts and cache
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
public/
|
||||||
|
meshchatx/public/
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
python-dist/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Local storage and runtime data
|
||||||
|
storage/
|
||||||
|
testing/
|
||||||
|
telemetry_test_lxmf/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
20
.github/workflows/bearer-pr.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
name: Bearer PR Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rule_check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
|
- name: Bearer
|
||||||
|
uses: bearer/bearer-action@828eeb928ce2f4a7ca5ed57fb8b59508cb8c79bc # v2
|
||||||
|
with:
|
||||||
|
diff: true
|
||||||
360
.github/workflows/build.yml
vendored
@@ -4,88 +4,37 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
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:
|
jobs:
|
||||||
build_windows:
|
build_frontend:
|
||||||
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: 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/*-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:
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Repo
|
- name: Clone Repo
|
||||||
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||||
@@ -93,35 +42,256 @@ jobs:
|
|||||||
- name: Install NodeJS
|
- name: Install NodeJS
|
||||||
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
|
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 22
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install Python Deps
|
- name: Sync versions
|
||||||
run: pip install -r requirements.txt
|
run: python scripts/sync_version.py
|
||||||
|
|
||||||
- name: Install NodeJS Deps
|
- name: Install NodeJS Deps
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Build Electron App
|
- name: Build Frontend
|
||||||
run: npm run dist
|
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
|
- name: Create Release
|
||||||
id: create_release
|
|
||||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
allowUpdates: true
|
artifacts: "release-assets/*"
|
||||||
replacesArtifacts: true
|
bodyFile: "release-assets/release-body.md"
|
||||||
omitDraftDuringUpdate: true
|
|
||||||
omitNameDuringUpdate: true
|
|
||||||
artifacts: "dist/*-linux.AppImage"
|
|
||||||
|
|
||||||
build_docker:
|
build_docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_docker == 'true')
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
@@ -152,9 +322,9 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: >-
|
tags: >-
|
||||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:latest,
|
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:latest,
|
||||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:${{ github.ref_name }}
|
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:${{ github.ref_name }}
|
||||||
labels: >-
|
labels: >-
|
||||||
org.opencontainers.image.title=Reticulum MeshChat,
|
org.opencontainers.image.title=Reticulum MeshChatX,
|
||||||
org.opencontainers.image.description=Docker image for Reticulum MeshChat,
|
org.opencontainers.image.description=Docker image for Reticulum MeshChatX,
|
||||||
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchat/
|
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,11 +1,57 @@
|
|||||||
|
# IDE and editor files
|
||||||
.idea
|
.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/
|
/build/
|
||||||
/dist/
|
/dist/
|
||||||
/public/
|
/meshchatx/public/
|
||||||
|
public/
|
||||||
/electron/build/exe/
|
/electron/build/exe/
|
||||||
|
python-dist/
|
||||||
|
|
||||||
# local storage
|
# Local storage and runtime data
|
||||||
storage/
|
storage/
|
||||||
|
testing/
|
||||||
|
telemetry_test_lxmf/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
33
Dockerfile
@@ -1,33 +1,42 @@
|
|||||||
|
# Build arguments
|
||||||
|
ARG NODE_VERSION=20
|
||||||
|
ARG NODE_ALPINE_SHA256=sha256:6a91081a440be0b57336fbc4ee87f3dab1a2fd6f80cdb355dcf960e13bda3b59
|
||||||
|
ARG PYTHON_VERSION=3.11
|
||||||
|
ARG PYTHON_ALPINE_SHA256=sha256:822ceb965f026bc47ee667e50a44309d2d81087780bbbf64f2005521781a3621
|
||||||
|
|
||||||
# Build the frontend
|
# Build the frontend
|
||||||
FROM node:20-bookworm-slim AS build-frontend
|
FROM node:${NODE_VERSION}-alpine@${NODE_ALPINE_SHA256} AS build-frontend
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy required source files
|
# Copy required source files
|
||||||
COPY *.json .
|
COPY package*.json vite.config.js ./
|
||||||
COPY *.js .
|
COPY meshchatx ./meshchatx
|
||||||
COPY src/frontend ./src/frontend
|
|
||||||
|
|
||||||
# Install NodeJS deps, exluding electron
|
# Install NodeJS deps, exluding electron
|
||||||
RUN npm install --omit=dev && \
|
RUN npm install --omit=dev && \
|
||||||
npm run build-frontend
|
npm run build-frontend
|
||||||
|
|
||||||
# Main app build
|
# Main app build
|
||||||
FROM python:3.11-bookworm
|
FROM python:${PYTHON_VERSION}-alpine@${PYTHON_ALPINE_SHA256}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python deps
|
# Install Python deps
|
||||||
COPY ./requirements.txt .
|
COPY ./requirements.txt .
|
||||||
RUN pip install -r requirements.txt
|
RUN apk add --no-cache --virtual .build-deps \
|
||||||
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
linux-headers \
|
||||||
|
python3-dev && \
|
||||||
|
pip install -r requirements.txt && \
|
||||||
|
apk del .build-deps
|
||||||
|
|
||||||
# Copy prebuilt frontend
|
# Copy prebuilt frontend
|
||||||
COPY --from=build-frontend /src/public public
|
COPY --from=build-frontend /src/meshchatx/public meshchatx/public
|
||||||
|
|
||||||
# Copy other required source files
|
# Copy other required source files
|
||||||
COPY *.py .
|
COPY meshchatx ./meshchatx
|
||||||
COPY src/__init__.py ./src/__init__.py
|
COPY pyproject.toml poetry.lock ./
|
||||||
COPY src/backend ./src/backend
|
|
||||||
COPY *.json .
|
|
||||||
|
|
||||||
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"]
|
||||||
|
|||||||
87
Makefile
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
.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
|
||||||
|
|
||||||
|
PYTHON ?= python
|
||||||
|
POETRY = $(PYTHON) -m poetry
|
||||||
|
NPM = npm
|
||||||
|
LEGACY_ELECTRON_VERSION ?= 30.0.8
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
install: sync-version node_modules python
|
||||||
|
|
||||||
|
node_modules:
|
||||||
|
$(NPM) install
|
||||||
|
|
||||||
|
python:
|
||||||
|
$(POETRY) install
|
||||||
|
|
||||||
|
run: install
|
||||||
|
$(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 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
|
||||||
349
README.md
@@ -1,297 +1,110 @@
|
|||||||
<p align="center">
|
# Reticulum MeshChatX
|
||||||
<a href="https://github.com/liamcottle/reticulum-meshchat"><img src="./logo/logo-chat-bubble.png" width="150"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 align="center">Reticulum MeshChat</h2>
|
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.
|
||||||
|
|
||||||
<p align="center">
|
## Features of this fork
|
||||||
<a href="https://discord.gg/APQSQZNV7t"><img src="https://img.shields.io/badge/Discord-Liam%20Cottle's%20Discord-%237289DA?style=flat&logo=discord" alt="discord"/></a>
|
|
||||||
<a href="https://twitter.com/liamcottle"><img src="https://img.shields.io/badge/Twitter-@liamcottle-%231DA1F2?style=flat&logo=twitter" alt="twitter"/></a>
|
|
||||||
<br/>
|
|
||||||
<a href="https://ko-fi.com/liamcottle"><img src="https://img.shields.io/badge/Donate%20a%20Coffee-liamcottle-yellow?style=flat&logo=buy-me-a-coffee" alt="donate on ko-fi"/></a>
|
|
||||||
<a href="./donate.md"><img src="https://img.shields.io/badge/Donate%20Bitcoin-bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q-%23FF9900?style=flat&logo=bitcoin" alt="donate bitcoin"/></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## What is Reticulum MeshChat?
|
- [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.
|
||||||
|
|
||||||
A simple mesh network communications app powered by the [Reticulum Network Stack](https://github.com/markqvist/Reticulum).
|
## Usage
|
||||||
|
|
||||||
<img src="./screenshots/screenshot.png">
|
Check [releases](https://github.com/Sudo-Ivan/reticulum-meshchatX/releases) for pre-built binaries or appimages.
|
||||||
|
|
||||||
## What does it do?
|
## Building
|
||||||
|
|
||||||
- It can send and receive messages, files and audio calls with peers;
|
```bash
|
||||||
- Over your local network through Ethernet and WiFi, completely automatically.
|
make install # installs Python deps via Poetry and Node deps via npm
|
||||||
- Over the internet by connecting through a server [hosted by yourself](https://reticulum.network/manual/interfaces.html#tcp-server-interface) or [the community](https://reticulum.network/connect.html).
|
make build
|
||||||
- Over low-powered, license-free, ISM band LoRa Radio, with an [RNode](https://github.com/markqvist/RNode_Firmware).
|
|
||||||
- ...and via [any other interface](https://reticulum.network/manual/interfaces.html) supported by the Reticulum Network Stack.
|
|
||||||
- It communicates securely. Messages can only be decrypted by the intended destination.
|
|
||||||
- It can communicate with any other existing [LXMF](https://github.com/markqvist/lxmf) client, such as [Sideband](https://github.com/markqvist/Sideband/) and [Nomadnet](https://github.com/markqvist/nomadnet).
|
|
||||||
- It can download files and browse micron pages (decentralised websites) hosted on [Nomad Network](https://github.com/markqvist/nomadnet) nodes.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Supports sending and receiving messages between [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat), [Sideband](https://github.com/markqvist/Sideband/) and [Nomadnet](https://github.com/markqvist/nomadnet).
|
|
||||||
- Supports receiving and saving images and attachments sent from Sideband.
|
|
||||||
- Supports sending images, voice recordings and file attachments.
|
|
||||||
- Supports saving inbound and outbound messages to a local database.
|
|
||||||
- Supports sending an announce to the network.
|
|
||||||
- Supports setting a custom display name to send in your announce.
|
|
||||||
- Supports viewing and searching peers discovered from announces.
|
|
||||||
- Supports auto resending undelivered messages when an announce is received from the recipient.
|
|
||||||
- Supports sending messages to and syncing messages from [LXMF Propagation Nodes](https://github.com/markqvist/lxmf?tab=readme-ov-file#propagation-nodes).
|
|
||||||
- Supports running a local LXMF Propagation Node so other users can use your device for message storage and retrieval.
|
|
||||||
- Support for browsing pages, and downloading files hosted on Nomad Network Nodes.
|
|
||||||
|
|
||||||
## Beta Features
|
|
||||||
|
|
||||||
- Support for Audio Calls to other [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat) users.
|
|
||||||
- Audio is encoded with [codec2](https://github.com/drowe67/codec2) to support low bandwidth links.
|
|
||||||
- Using a microphone requires using the web ui over localhost or https, due to [AudioWorklet](https://developer.mozilla.org/en-US/docs/Web/API/AudioWorklet) secure context.
|
|
||||||
- I have tested two-way audio calls over LoRa with a single hop. It works well when a [reasonable bitrate](https://unsigned.io/understanding-lora-parameters/) is configured on the RNode.
|
|
||||||
- Some browsers such as FireFox don't work as expected. Try using a Chromium based browser if running via the command line.
|
|
||||||
|
|
||||||
## Download
|
|
||||||
|
|
||||||
You can download the latest version for Windows, Mac and Linux from the [releases](https://github.com/liamcottle/reticulum-meshchat/releases) page.
|
|
||||||
|
|
||||||
Alternatively, you can download the source and run it manually from a command line.
|
|
||||||
|
|
||||||
See the ["How to use it?"](#how-to-use-it) section, further down on how to do this.
|
|
||||||
|
|
||||||
## Other Installation Methods
|
|
||||||
|
|
||||||
- [Running MeshChat on Docker](./docs/meshchat_on_docker.md)
|
|
||||||
- [Running MeshChat on a Raspberry Pi](./docs/meshchat_on_raspberry_pi.md)
|
|
||||||
- [Running MeshChat on Android with Termux](./docs/meshchat_on_android_with_termux.md)
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
Once you've downloaded, installed and launched Reticulum MeshChat, there's a few things you need to do in order to start communicating with other people on the network.
|
|
||||||
|
|
||||||
1. Create an Identity
|
|
||||||
2. Configure your Display Name
|
|
||||||
3. Send an Announce
|
|
||||||
4. Discover Peers and start sending messages
|
|
||||||
5. Configuring additional Network Interfaces
|
|
||||||
|
|
||||||
**Create an Identity**
|
|
||||||
|
|
||||||
On the Reticulum Network, anyone can have any number of Identities. You may opt to use your real name, or you may decide to be completely anonymous. The choice is yours.
|
|
||||||
|
|
||||||
A Reticulum Identity is a public/private key-pair. You control the private key used to generate destination addresses, encrypt content and prove receipt of data with unforgeable delivery acknowledgements.
|
|
||||||
|
|
||||||
Your public key is shared with the network when you send an announce, and allows others on the network to automatically discover a route to a destination you control.
|
|
||||||
|
|
||||||
At this time, Reticulum MeshChat generates a new Identity the first time you launch it. A future update will allow you to create and manage multiple identities.
|
|
||||||
|
|
||||||
For now, if you want to change, or reset your identity, you can access the identity file at `~/.reticulum-meshchat/identity`.
|
|
||||||
|
|
||||||
**Configure your Display Name**
|
|
||||||
|
|
||||||
The next thing you should do, is set a display name. Your display name is what everyone else on the network will see when looking for someone to communicate with from the Peers list.
|
|
||||||
|
|
||||||
You can do this in the `My Identity` section in the bottom left corner. Enter a new display name, and then press `Save`.
|
|
||||||
|
|
||||||
**Send an Announce**
|
|
||||||
|
|
||||||
When using the Reticulum Network, in order to be contactable, you need to send an `Announce`. You can send an announce as often, or as infrequently as you like.
|
|
||||||
|
|
||||||
Sending an announce allows other peers on the network to discover the next-hop across the network their packets should take to arrive at a destination that your identity controls.
|
|
||||||
|
|
||||||
If you never send an announce, you will be invisible and no one will ever be able to send anything to you.
|
|
||||||
|
|
||||||
When you move across the network, and change entrypoints, such as moving from your home WiFi network, to plugging in to an Ethernet port in a local library or even climbing a mountain and using an RNode over LoRa radio, other peers on the network will only know the previous path to your destinations.
|
|
||||||
|
|
||||||
To allow them to discover the new path their packets should take to reach you, you should send an announce.
|
|
||||||
|
|
||||||
**Discover Peers and start sending messages**
|
|
||||||
|
|
||||||
In the Reticulum Network, you can control an unlimited number of destination addresses. One of these can be an [LXMF](https://github.com/markqvist/lxmf) delivery address.
|
|
||||||
|
|
||||||
Your Reticulum Identity allows you to have an LXMF address. Think of an LXMF address as your very own, secure, end-to-end encrypted, unspoofable, email address routed over a mesh network.
|
|
||||||
|
|
||||||
When someone else on the network announces themselves (more specifically, their LXMF address), they will show up in the Peers tab.
|
|
||||||
|
|
||||||
You can click on any of these discovered peers to open a messaging interface. From here, you can send text messages, files and inline images. If they respond, their messages will show up there too.
|
|
||||||
|
|
||||||
As well as being able to announce your LXMF address and discover others, Reticulum MeshChat can also discover [Nomad Network](https://github.com/markqvist/nomadnet) nodes hosted by other users. From the Nodes tab, you are free to explore pages and download files they may be publicly sharing on the network.
|
|
||||||
|
|
||||||
A future update is planned to allow you to host your own Node and share pages and files with other peers on the network. For now, you could use the official [Nomad Network](https://github.com/markqvist/nomadnet) client to do this.
|
|
||||||
|
|
||||||
Remember, in order to connect with other peers or nodes, they must announce on the network. So don't forget to announce if you want to be discovered!
|
|
||||||
|
|
||||||
**Configuring additional Network Interfaces**
|
|
||||||
|
|
||||||
> TODO: this section is yet to be written. For now, you can check out the [official documentation for configuring interfaces](https://reticulum.network/manual/interfaces.html) in the Reticulum config file. This file is located at `~/.reticulum/config`
|
|
||||||
|
|
||||||
## How does it work?
|
|
||||||
|
|
||||||
- A python script ([meshchat.py](./meshchat.py)) runs a Reticulum instance and a WebSocket server.
|
|
||||||
- The web page sends and receives LXMF packets encoded in json via the WebSocket.
|
|
||||||
- Web Browser -> WebSocket -> Python Reticulum -> (configured interfaces) -> (destination)
|
|
||||||
- LXMF messages sent and received are saved to a local SQLite database.
|
|
||||||
|
|
||||||
## How to use it?
|
|
||||||
|
|
||||||
It is recommended that you [download](#download) a standalone application.
|
|
||||||
|
|
||||||
If you don't want to, or a release is unavailable for your device, you will need to;
|
|
||||||
|
|
||||||
- install [Python 3](https://www.python.org/downloads/)
|
|
||||||
- install [NodeJS v18+](https://nodejs.org/en)
|
|
||||||
- clone the source code from this repo
|
|
||||||
- install all dependencies
|
|
||||||
- then run `meshchat.py`.
|
|
||||||
|
|
||||||
```
|
|
||||||
# clone repo
|
|
||||||
git clone https://github.com/liamcottle/reticulum-meshchat
|
|
||||||
cd reticulum-meshchat
|
|
||||||
|
|
||||||
# install nodejs deps
|
|
||||||
# if you want to build electron binaries, remove "--omit=dev"
|
|
||||||
# if you're using termux, add "--ignore-scripts" to fix error with esbuild
|
|
||||||
npm install --omit=dev
|
|
||||||
|
|
||||||
# build frontend vue components
|
|
||||||
npm run build-frontend
|
|
||||||
|
|
||||||
# install python deps
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# run meshchat
|
|
||||||
python meshchat.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> NOTE: You should now be able to access the web interface at http://localhost:8000
|
You can run `make run` or `make develop` (a thin alias) to start the backend + frontend loop locally through `poetry run meshchat`.
|
||||||
|
|
||||||
For a full list of command line options, you can run;
|
### 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:
|
||||||
python meshchat.py --help
|
|
||||||
|
```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.
|
||||||
usage: meshchat.py [-h] [--host [HOST]] [--port [PORT]] [--headless] [--identity-file IDENTITY_FILE] [--identity-base64 IDENTITY_BASE64] [--generate-identity-file GENERATE_IDENTITY_FILE] [--generate-identity-base64]
|
|
||||||
[--reticulum-config-dir RETICULUM_CONFIG_DIR] [--storage-dir STORAGE_DIR]
|
|
||||||
|
|
||||||
ReticulumMeshChat
|
### Building in Docker
|
||||||
|
|
||||||
options:
|
```bash
|
||||||
-h, --help show this help message and exit
|
make build-docker
|
||||||
--host [HOST] The address the web server should listen on.
|
|
||||||
--port [PORT] The port the web server should listen on.
|
|
||||||
--headless Web browser will not automatically launch when this flag is passed.
|
|
||||||
--identity-file IDENTITY_FILE
|
|
||||||
Path to a Reticulum Identity file to use as your LXMF address.
|
|
||||||
--identity-base64 IDENTITY_BASE64
|
|
||||||
A base64 encoded Reticulum Identity to use as your LXMF address.
|
|
||||||
--generate-identity-file GENERATE_IDENTITY_FILE
|
|
||||||
Generates and saves a new Reticulum Identity to the provided file path and then exits.
|
|
||||||
--generate-identity-base64
|
|
||||||
Outputs a randomly generated Reticulum Identity as base64 and then exits.
|
|
||||||
--reticulum-config-dir RETICULUM_CONFIG_DIR
|
|
||||||
Path to a Reticulum config directory for the RNS stack to use (e.g: ~/.reticulum)
|
|
||||||
--storage-dir STORAGE_DIR
|
|
||||||
Path to a directory for storing databases and config files (default: ./storage)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Using an existing Reticulum Identity
|
`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`.
|
||||||
|
|
||||||
The first time you run this application, a new Reticulum identity is generated and saved to `storage/identity`.
|
### Running with Docker Compose
|
||||||
|
|
||||||
If you want to use an existing identity;
|
```bash
|
||||||
|
make run-docker
|
||||||
- You can overwrite `storage/identity` with another identity file.
|
|
||||||
- Or, you can pass in a custom identity file path as a command line argument.
|
|
||||||
|
|
||||||
To use a custom identity file, provide the `--identity-file` argument followed by the path to your custom identity file.
|
|
||||||
|
|
||||||
```
|
|
||||||
python meshchat.py --identity-file ./custom_identity_file
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you would like to generate a new identity, you can use the [rnid](https://reticulum.network/manual/using.html#the-rnid-utility) utility provided by Reticulum.
|
`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.
|
||||||
rnid --generate ./new_identity_file
|
|
||||||
|
## 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
|
||||||
```
|
```
|
||||||
|
|
||||||
If you don't have access to the `rnid` command, you can use the following:
|
### Building with pip (alternative)
|
||||||
|
|
||||||
```
|
If you prefer pip, you can build/install directly:
|
||||||
python meshchat.py --generate-identity-file ./new_identity_file
|
|
||||||
|
```bash
|
||||||
|
# Build the wheel
|
||||||
|
pip install build
|
||||||
|
python -m build
|
||||||
|
|
||||||
|
# Install locally
|
||||||
|
pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, you can provide a base64 encoded private key, like so;
|
### 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.
|
||||||
python meshchat.py --identity-base64 "GCN6mMhVemdNIK/fw97C1zvU17qjQPFTXRBotVckeGmoOwQIF8VOjXwNNem3CUOJZCQQpJuc/4U94VSsC39Phw=="
|
|
||||||
```
|
|
||||||
|
|
||||||
> NOTE: this is a randomly generated identity for example purposes. Do not use it, it has been leaked!
|
|
||||||
|
|
||||||
## Build Electron Application
|
|
||||||
|
|
||||||
Reticulum MeshChat can be run from source via a command line, as explained above, or as a standalone application.
|
|
||||||
|
|
||||||
To run as a standalone application, we need to compile the python script and dependencies to an executable with [cxfreeze](https://github.com/marcelotduarte/cx_Freeze) and then build an [Electron](https://www.electronjs.org/) app which includes a bundled browser that can interact with the compiled python executable.
|
|
||||||
|
|
||||||
This allows for the entire application to be run by double clicking a single file without the need for a user to manually install python, nor run any commands in a command line application.
|
|
||||||
|
|
||||||
To build a `.exe` when running on Windows or a `.dmg` when running on a Mac, run the following;
|
|
||||||
|
|
||||||
```
|
|
||||||
pip install -r requirements.txt
|
|
||||||
npm install
|
|
||||||
npm run dist
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note: cxfreeze only supports building an executable for the current platform. You will need a Mac to build for Mac, and a Windows PC to build for Windows.
|
|
||||||
|
|
||||||
Once completed, you should have a `.exe` or a `.dmg` in the `dist` folder.
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
I normally run the following commands to work on the project locally.
|
|
||||||
|
|
||||||
**Install dependencies**
|
|
||||||
|
|
||||||
```
|
|
||||||
pip install -r requirements.txt
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
**Build and run Electron App**
|
|
||||||
|
|
||||||
```
|
|
||||||
npm run electron
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**or; Build and run MeshChat Server**
|
|
||||||
|
|
||||||
```
|
|
||||||
npm run build-frontend
|
|
||||||
python3 meshchat.py --headless
|
|
||||||
```
|
|
||||||
|
|
||||||
I build the vite app everytime without hot reload, since MeshChat expects everything over its own port, not the vite server port. I will attempt to fix this in the future.
|
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
- [ ] button to forget announces
|
|
||||||
|
|
||||||
# Notes
|
|
||||||
|
|
||||||
**LXMF Router**
|
|
||||||
|
|
||||||
- By default, the LXMF router rejects inbound messages larger than 1mb.
|
|
||||||
- LXMF clients are likely to have [this default limit](https://github.com/markqvist/LXMF/blob/c426c93cc5d63a3dae18ad2264b1299a7ad9e46c/LXMF/LXMRouter.py#L38), and your messages will [fail to send](https://github.com/markqvist/LXMF/blob/c426c93cc5d63a3dae18ad2264b1299a7ad9e46c/LXMF/LXMRouter.py#L1428).
|
|
||||||
- MeshChat has increased the receive limit to 10mb to allow for larger attachments.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|||||||
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:
|
services:
|
||||||
reticulum-meshchat:
|
reticulum-meshchatx:
|
||||||
container_name: reticulum-meshchat
|
container_name: reticulum-meshchatx
|
||||||
image: ghcr.io/liamcottle/reticulum-meshchat:latest
|
image: ${MESHCHAT_IMAGE:-ghcr.io/sudo-ivan/reticulum-meshchatx:latest}
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Make the meshchat web interface accessible from the host on port 8000
|
# Make the meshchat web interface accessible from the host on port 8000
|
||||||
ports:
|
ports:
|
||||||
- 0.0.0.0:8000:8000
|
- 127.0.0.1:8000:8000
|
||||||
volumes:
|
volumes:
|
||||||
- meshchat-config:/config
|
- meshchat-config:/config
|
||||||
# Uncomment if you have a USB device connected, such as an RNode
|
# 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: {
|
webPreferences: {
|
||||||
// used to inject logging over ipc
|
// used to inject logging over ipc
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
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
|
// find path to python/cxfreeze reticulum meshchat executable
|
||||||
const exeName = process.platform === "win32" ? "ReticulumMeshChat.exe" : "ReticulumMeshChat";
|
// Note: setup.py creates ReticulumMeshChatX (with X), not ReticulumMeshChat
|
||||||
var exe = path.join(__dirname, `build/exe/${exeName}`);
|
const exeName = process.platform === "win32" ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX";
|
||||||
|
|
||||||
// if dist exe doesn't exist, check local build
|
// get app path (handles both development and packaged app)
|
||||||
if(!fs.existsSync(exe)){
|
const appPath = app.getAppPath();
|
||||||
exe = path.join(__dirname, '..', `build/exe/${exeName}`);
|
// 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 {
|
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
|
# this class forces stream writes to be flushed immediately
|
||||||
class ImmediateFlushingStreamWrapper:
|
class ImmediateFlushingStreamWrapper:
|
||||||
|
|
||||||
def __init__(self, stream):
|
def __init__(self, stream):
|
||||||
self.stream = stream
|
self.stream = stream
|
||||||
|
|
||||||
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
|
# an announce handler that forwards announces to a provided callback for the provided aspect filter
|
||||||
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
|
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
|
||||||
class AnnounceHandler:
|
class AnnounceHandler:
|
||||||
|
|
||||||
def __init__(self, aspect_filter: str, received_announce_callback):
|
def __init__(self, aspect_filter: str, received_announce_callback):
|
||||||
self.aspect_filter = aspect_filter
|
self.aspect_filter = aspect_filter
|
||||||
self.received_announce_callback = received_announce_callback
|
self.received_announce_callback = received_announce_callback
|
||||||
|
|
||||||
# we will just pass the received announce back to the provided callback
|
# we will just pass the received announce back to the provided callback
|
||||||
def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash):
|
def received_announce(
|
||||||
|
self,
|
||||||
|
destination_hash,
|
||||||
|
announced_identity,
|
||||||
|
app_data,
|
||||||
|
announce_packet_hash,
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
# handle received announce
|
# handle received announce
|
||||||
self.received_announce_callback(self.aspect_filter, destination_hash, announced_identity, app_data, announce_packet_hash)
|
self.received_announce_callback(
|
||||||
except:
|
self.aspect_filter,
|
||||||
|
destination_hash,
|
||||||
|
announced_identity,
|
||||||
|
app_data,
|
||||||
|
announce_packet_hash,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
# ignore failure to handle received announce
|
# ignore failure to handle received announce
|
||||||
pass
|
pass
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Coroutine
|
from collections.abc import Coroutine
|
||||||
|
|
||||||
|
|
||||||
class AsyncUtils:
|
class AsyncUtils:
|
||||||
|
|
||||||
# remember main loop
|
# remember main loop
|
||||||
main_loop: asyncio.AbstractEventLoop | None = None
|
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
|
# it will run the async function on the main event loop if possible, otherwise it logs a warning
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def run_async(coroutine: Coroutine):
|
def run_async(coroutine: Coroutine):
|
||||||
|
|
||||||
# run provided coroutine on main event loop, ensuring thread safety
|
# run provided coroutine on main event loop, ensuring thread safety
|
||||||
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
|
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
|
||||||
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
|
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from typing import List
|
|
||||||
|
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
# todo optionally identity self over link
|
# TODO optionally identity self over link
|
||||||
# todo allowlist/denylist for incoming calls
|
# TODO allowlist/denylist for incoming calls
|
||||||
|
|
||||||
|
|
||||||
class CallFailedException(Exception):
|
class CallFailedException(Exception):
|
||||||
@@ -13,7 +12,6 @@ class CallFailedException(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class AudioCall:
|
class AudioCall:
|
||||||
|
|
||||||
def __init__(self, link: RNS.Link, is_outbound: bool):
|
def __init__(self, link: RNS.Link, is_outbound: bool):
|
||||||
self.link = link
|
self.link = link
|
||||||
self.is_outbound = is_outbound
|
self.is_outbound = is_outbound
|
||||||
@@ -41,21 +39,25 @@ class AudioCall:
|
|||||||
|
|
||||||
# handle packet received over link
|
# handle packet received over link
|
||||||
def on_packet(self, message, packet):
|
def on_packet(self, message, packet):
|
||||||
|
|
||||||
# send audio received from call initiator to all audio packet listeners
|
# send audio received from call initiator to all audio packet listeners
|
||||||
for audio_packet_listener in self.audio_packet_listeners:
|
for audio_packet_listener in self.audio_packet_listeners:
|
||||||
audio_packet_listener(message)
|
audio_packet_listener(message)
|
||||||
|
|
||||||
# send an audio packet over the link
|
# send an audio packet over the link
|
||||||
def send_audio_packet(self, data):
|
def send_audio_packet(self, data):
|
||||||
|
|
||||||
# do nothing if link is not active
|
# do nothing if link is not active
|
||||||
if self.is_active() is False:
|
if self.is_active() is False:
|
||||||
return
|
return
|
||||||
|
|
||||||
# drop audio packet if it is too big to send
|
# drop audio packet if it is too big to send
|
||||||
if len(data) > RNS.Link.MDU:
|
if len(data) > RNS.Link.MDU:
|
||||||
print("[AudioCall] dropping audio packet " + str(len(data)) + " bytes exceeds the link packet MDU of " + str(RNS.Link.MDU) + " bytes")
|
print(
|
||||||
|
"[AudioCall] dropping audio packet "
|
||||||
|
+ str(len(data))
|
||||||
|
+ " bytes exceeds the link packet MDU of "
|
||||||
|
+ str(RNS.Link.MDU)
|
||||||
|
+ " bytes",
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# send codec2 audio received from call receiver to call initiator over reticulum link
|
# send codec2 audio received from call receiver to call initiator over reticulum link
|
||||||
@@ -73,25 +75,26 @@ class AudioCall:
|
|||||||
def hangup(self):
|
def hangup(self):
|
||||||
print("[AudioCall] hangup")
|
print("[AudioCall] hangup")
|
||||||
self.link.teardown()
|
self.link.teardown()
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class AudioCallManager:
|
class AudioCallManager:
|
||||||
|
def __init__(self, identity: RNS.Identity, is_destination_blocked_callback=None):
|
||||||
def __init__(self, identity: RNS.Identity):
|
|
||||||
|
|
||||||
self.identity = identity
|
self.identity = identity
|
||||||
self.on_incoming_call_callback = None
|
self.on_incoming_call_callback = None
|
||||||
self.on_outgoing_call_callback = None
|
self.on_outgoing_call_callback = None
|
||||||
|
self.is_destination_blocked_callback = is_destination_blocked_callback
|
||||||
self.audio_call_receiver = AudioCallReceiver(manager=self)
|
self.audio_call_receiver = AudioCallReceiver(manager=self)
|
||||||
|
|
||||||
# remember audio calls
|
# remember audio calls
|
||||||
self.audio_calls: List[AudioCall] = []
|
self.audio_calls: list[AudioCall] = []
|
||||||
|
|
||||||
# announces the audio call destination
|
# announces the audio call destination
|
||||||
def announce(self, app_data=None):
|
def announce(self, app_data=None):
|
||||||
self.audio_call_receiver.destination.announce(app_data)
|
self.audio_call_receiver.destination.announce(app_data)
|
||||||
print("[AudioCallManager] announced destination: " + RNS.prettyhexrep(self.audio_call_receiver.destination.hash))
|
print(
|
||||||
|
"[AudioCallManager] announced destination: "
|
||||||
|
+ RNS.prettyhexrep(self.audio_call_receiver.destination.hash),
|
||||||
|
)
|
||||||
|
|
||||||
# set the callback for incoming calls
|
# set the callback for incoming calls
|
||||||
def register_incoming_call_callback(self, callback):
|
def register_incoming_call_callback(self, callback):
|
||||||
@@ -103,7 +106,6 @@ class AudioCallManager:
|
|||||||
|
|
||||||
# handle incoming calls from audio call receiver
|
# handle incoming calls from audio call receiver
|
||||||
def handle_incoming_call(self, audio_call: AudioCall):
|
def handle_incoming_call(self, audio_call: AudioCall):
|
||||||
|
|
||||||
# remember it
|
# remember it
|
||||||
self.audio_calls.append(audio_call)
|
self.audio_calls.append(audio_call)
|
||||||
|
|
||||||
@@ -113,7 +115,6 @@ class AudioCallManager:
|
|||||||
|
|
||||||
# handle outgoing calls
|
# handle outgoing calls
|
||||||
def handle_outgoing_call(self, audio_call: AudioCall):
|
def handle_outgoing_call(self, audio_call: AudioCall):
|
||||||
|
|
||||||
# remember it
|
# remember it
|
||||||
self.audio_calls.append(audio_call)
|
self.audio_calls.append(audio_call)
|
||||||
|
|
||||||
@@ -142,22 +143,26 @@ class AudioCallManager:
|
|||||||
def hangup_all(self):
|
def hangup_all(self):
|
||||||
for audio_call in self.audio_calls:
|
for audio_call in self.audio_calls:
|
||||||
audio_call.hangup()
|
audio_call.hangup()
|
||||||
return None
|
|
||||||
|
|
||||||
# attempts to initiate a call to the provided destination and returns the link hash on success
|
# attempts to initiate a call to the provided destination and returns the link hash on success
|
||||||
async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15) -> AudioCall:
|
async def initiate(
|
||||||
|
self,
|
||||||
|
destination_hash: bytes,
|
||||||
|
timeout_seconds: int = 15,
|
||||||
|
) -> AudioCall:
|
||||||
# determine when to timeout
|
# determine when to timeout
|
||||||
timeout_after_seconds = time.time() + timeout_seconds
|
timeout_after_seconds = time.time() + timeout_seconds
|
||||||
|
|
||||||
# check if we have a path to the destination
|
# check if we have a path to the destination
|
||||||
if not RNS.Transport.has_path(destination_hash):
|
if not RNS.Transport.has_path(destination_hash):
|
||||||
|
|
||||||
# we don't have a path, so we need to request it
|
# we don't have a path, so we need to request it
|
||||||
RNS.Transport.request_path(destination_hash)
|
RNS.Transport.request_path(destination_hash)
|
||||||
|
|
||||||
# wait until we have a path, or give up after the configured timeout
|
# wait until we have a path, or give up after the configured timeout
|
||||||
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after_seconds:
|
while (
|
||||||
|
not RNS.Transport.has_path(destination_hash)
|
||||||
|
and time.time() < timeout_after_seconds
|
||||||
|
):
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# if we still don't have a path, we can't establish a link, so bail out
|
# if we still don't have a path, we can't establish a link, so bail out
|
||||||
@@ -171,14 +176,16 @@ class AudioCallManager:
|
|||||||
RNS.Destination.OUT,
|
RNS.Destination.OUT,
|
||||||
RNS.Destination.SINGLE,
|
RNS.Destination.SINGLE,
|
||||||
"call",
|
"call",
|
||||||
"audio"
|
"audio",
|
||||||
)
|
)
|
||||||
|
|
||||||
# create link
|
# create link
|
||||||
link = RNS.Link(server_destination)
|
link = RNS.Link(server_destination)
|
||||||
|
|
||||||
# wait until we have established a link, or give up after the configured timeout
|
# wait until we have established a link, or give up after the configured timeout
|
||||||
while link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds:
|
while (
|
||||||
|
link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds
|
||||||
|
):
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# if we still haven't established a link, bail out
|
# if we still haven't established a link, bail out
|
||||||
@@ -191,16 +198,14 @@ class AudioCallManager:
|
|||||||
# handle new outgoing call
|
# handle new outgoing call
|
||||||
self.handle_outgoing_call(audio_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)
|
link.identify(self.identity)
|
||||||
|
|
||||||
return audio_call
|
return audio_call
|
||||||
|
|
||||||
|
|
||||||
class AudioCallReceiver:
|
class AudioCallReceiver:
|
||||||
|
|
||||||
def __init__(self, manager: AudioCallManager):
|
def __init__(self, manager: AudioCallManager):
|
||||||
|
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
|
|
||||||
# create destination for receiving audio calls
|
# create destination for receiving audio calls
|
||||||
@@ -224,8 +229,24 @@ class AudioCallReceiver:
|
|||||||
|
|
||||||
# client connected to us, set up an audio call instance
|
# client connected to us, set up an audio call instance
|
||||||
def client_connected(self, link: RNS.Link):
|
def client_connected(self, link: RNS.Link):
|
||||||
|
# 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)
|
link.identify(self.manager.identity)
|
||||||
|
|
||||||
# create audio call
|
# create audio call
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
class ColourUtils:
|
class ColourUtils:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def hex_colour_to_byte_array(hex_colour):
|
def hex_colour_to_byte_array(hex_colour):
|
||||||
|
|
||||||
# remove leading "#"
|
# remove leading "#"
|
||||||
hex_colour = hex_colour.lstrip('#')
|
hex_colour = hex_colour.lstrip("#")
|
||||||
|
|
||||||
# convert the remaining hex string to bytes
|
# convert the remaining hex string to bytes
|
||||||
return bytes.fromhex(hex_colour)
|
return bytes.fromhex(hex_colour)
|
||||||
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:
|
class InterfaceEditor:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_value(interface_details: dict, data: dict, key: str):
|
def update_value(interface_details: dict, data: dict, key: str):
|
||||||
|
|
||||||
# update value if provided and not empty
|
# update value if provided and not empty
|
||||||
value = data.get(key)
|
value = data.get(key)
|
||||||
if value is not None and value != "":
|
if value is not None and value != "":
|
||||||
@@ -10,5 +8,4 @@ class InterfaceEditor:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# otherwise remove existing value
|
# otherwise remove existing value
|
||||||
if key in interface_details:
|
interface_details.pop(key, None)
|
||||||
del interface_details[key]
|
|
||||||
@@ -8,7 +8,6 @@ from websockets.sync.connection import Connection
|
|||||||
|
|
||||||
|
|
||||||
class WebsocketClientInterface(Interface):
|
class WebsocketClientInterface(Interface):
|
||||||
|
|
||||||
# TODO: required?
|
# TODO: required?
|
||||||
DEFAULT_IFAC_SIZE = 16
|
DEFAULT_IFAC_SIZE = 16
|
||||||
|
|
||||||
@@ -18,7 +17,6 @@ class WebsocketClientInterface(Interface):
|
|||||||
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
|
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
|
||||||
|
|
||||||
def __init__(self, owner, configuration, websocket: Connection = None):
|
def __init__(self, owner, configuration, websocket: Connection = None):
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
@@ -26,8 +24,8 @@ class WebsocketClientInterface(Interface):
|
|||||||
|
|
||||||
self.IN = True
|
self.IN = True
|
||||||
self.OUT = False
|
self.OUT = False
|
||||||
self.HW_MTU = 262144 # 256KiB
|
self.HW_MTU = 262144 # 256KiB
|
||||||
self.bitrate = 1_000_000_000 # 1Gbps
|
self.bitrate = 1_000_000_000 # 1Gbps
|
||||||
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||||
|
|
||||||
# parse config
|
# parse config
|
||||||
@@ -48,7 +46,6 @@ class WebsocketClientInterface(Interface):
|
|||||||
|
|
||||||
# called when a full packet has been received over the websocket
|
# called when a full packet has been received over the websocket
|
||||||
def process_incoming(self, data):
|
def process_incoming(self, data):
|
||||||
|
|
||||||
# do nothing if offline or detached
|
# do nothing if offline or detached
|
||||||
if not self.online or self.detached:
|
if not self.online or self.detached:
|
||||||
return
|
return
|
||||||
@@ -65,7 +62,6 @@ class WebsocketClientInterface(Interface):
|
|||||||
|
|
||||||
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
|
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
|
||||||
def process_outgoing(self, data):
|
def process_outgoing(self, data):
|
||||||
|
|
||||||
# do nothing if offline or detached
|
# do nothing if offline or detached
|
||||||
if not self.online or self.detached:
|
if not self.online or self.detached:
|
||||||
return
|
return
|
||||||
@@ -74,8 +70,11 @@ class WebsocketClientInterface(Interface):
|
|||||||
try:
|
try:
|
||||||
self.websocket.send(data)
|
self.websocket.send(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
RNS.log(f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR)
|
RNS.log(
|
||||||
RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR)
|
f"Exception occurred while transmitting via {self!s}",
|
||||||
|
RNS.LOG_ERROR,
|
||||||
|
)
|
||||||
|
RNS.log(f"The contained exception was: {e!s}", RNS.LOG_ERROR)
|
||||||
return
|
return
|
||||||
|
|
||||||
# update sent bytes counter
|
# update sent bytes counter
|
||||||
@@ -87,27 +86,29 @@ class WebsocketClientInterface(Interface):
|
|||||||
|
|
||||||
# connect to the configured websocket server
|
# connect to the configured websocket server
|
||||||
def connect(self):
|
def connect(self):
|
||||||
|
|
||||||
# do nothing if interface is detached
|
# do nothing if interface is detached
|
||||||
if self.detached:
|
if self.detached:
|
||||||
return
|
return
|
||||||
|
|
||||||
# connect to websocket server
|
# connect to websocket server
|
||||||
try:
|
try:
|
||||||
RNS.log(f"Connecting to Websocket for {str(self)}...", RNS.LOG_DEBUG)
|
RNS.log(f"Connecting to Websocket for {self!s}...", RNS.LOG_DEBUG)
|
||||||
self.websocket = connect(f"{self.target_url}", max_size=None, compression=None)
|
self.websocket = connect(
|
||||||
RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG)
|
f"{self.target_url}",
|
||||||
|
max_size=None,
|
||||||
|
compression=None,
|
||||||
|
)
|
||||||
|
RNS.log(f"Connected to Websocket for {self!s}", RNS.LOG_DEBUG)
|
||||||
self.read_loop()
|
self.read_loop()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
|
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
|
||||||
|
|
||||||
# auto reconnect after delay
|
# 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)
|
time.sleep(self.RECONNECT_DELAY_SECONDS)
|
||||||
self.connect()
|
self.connect()
|
||||||
|
|
||||||
def read_loop(self):
|
def read_loop(self):
|
||||||
|
|
||||||
self.online = True
|
self.online = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -119,7 +120,6 @@ class WebsocketClientInterface(Interface):
|
|||||||
self.online = False
|
self.online = False
|
||||||
|
|
||||||
def detach(self):
|
def detach(self):
|
||||||
|
|
||||||
# mark as offline
|
# mark as offline
|
||||||
self.online = False
|
self.online = False
|
||||||
|
|
||||||
@@ -130,5 +130,6 @@ class WebsocketClientInterface(Interface):
|
|||||||
# mark as detached
|
# mark as detached
|
||||||
self.detached = True
|
self.detached = True
|
||||||
|
|
||||||
|
|
||||||
# set interface class RNS should use when importing this external interface
|
# set interface class RNS should use when importing this external interface
|
||||||
interface_class = WebsocketClientInterface
|
interface_class = WebsocketClientInterface
|
||||||
@@ -3,33 +3,30 @@ import time
|
|||||||
|
|
||||||
import RNS
|
import RNS
|
||||||
from RNS.Interfaces.Interface import Interface
|
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 src.backend.interfaces.WebsocketClientInterface import WebsocketClientInterface
|
||||||
|
from websockets.sync.server import Server, ServerConnection, serve
|
||||||
|
|
||||||
|
|
||||||
class WebsocketServerInterface(Interface):
|
class WebsocketServerInterface(Interface):
|
||||||
|
|
||||||
# TODO: required?
|
# TODO: required?
|
||||||
DEFAULT_IFAC_SIZE = 16
|
DEFAULT_IFAC_SIZE = 16
|
||||||
|
|
||||||
RESTART_DELAY_SECONDS = 5
|
RESTART_DELAY_SECONDS = 5
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
|
return (
|
||||||
|
f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, owner, configuration):
|
def __init__(self, owner, configuration):
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
|
|
||||||
self.IN = True
|
self.IN = True
|
||||||
self.OUT = False
|
self.OUT = False
|
||||||
self.HW_MTU = 262144 # 256KiB
|
self.HW_MTU = 262144 # 256KiB
|
||||||
self.bitrate = 1_000_000_000 # 1Gbps
|
self.bitrate = 1_000_000_000 # 1Gbps
|
||||||
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||||
|
|
||||||
self.server: Server | None = None
|
self.server: Server | None = None
|
||||||
@@ -61,12 +58,12 @@ class WebsocketServerInterface(Interface):
|
|||||||
def clients(self):
|
def clients(self):
|
||||||
return len(self.spawned_interfaces)
|
return len(self.spawned_interfaces)
|
||||||
|
|
||||||
# todo docs
|
# TODO docs
|
||||||
def received_announce(self, from_spawned=False):
|
def received_announce(self, from_spawned=False):
|
||||||
if from_spawned:
|
if from_spawned:
|
||||||
self.ia_freq_deque.append(time.time())
|
self.ia_freq_deque.append(time.time())
|
||||||
|
|
||||||
# todo docs
|
# TODO docs
|
||||||
def sent_announce(self, from_spawned=False):
|
def sent_announce(self, from_spawned=False):
|
||||||
if from_spawned:
|
if from_spawned:
|
||||||
self.oa_freq_deque.append(time.time())
|
self.oa_freq_deque.append(time.time())
|
||||||
@@ -80,17 +77,19 @@ class WebsocketServerInterface(Interface):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def serve(self):
|
def serve(self):
|
||||||
|
|
||||||
# handle new websocket client connections
|
# handle new websocket client connections
|
||||||
def on_websocket_client_connected(websocket: ServerConnection):
|
def on_websocket_client_connected(websocket: ServerConnection):
|
||||||
|
|
||||||
# create new child interface
|
# create new child interface
|
||||||
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
|
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
|
||||||
spawned_interface = WebsocketClientInterface(self.owner, {
|
spawned_interface = WebsocketClientInterface(
|
||||||
"name": f"Client on {self.name}",
|
self.owner,
|
||||||
"target_host": websocket.remote_address[0],
|
{
|
||||||
"target_port": str(websocket.remote_address[1]),
|
"name": f"Client on {self.name}",
|
||||||
}, websocket=websocket)
|
"target_host": websocket.remote_address[0],
|
||||||
|
"target_port": str(websocket.remote_address[1]),
|
||||||
|
},
|
||||||
|
websocket=websocket,
|
||||||
|
)
|
||||||
|
|
||||||
# configure child interface
|
# configure child interface
|
||||||
spawned_interface.IN = self.IN
|
spawned_interface.IN = self.IN
|
||||||
@@ -101,16 +100,19 @@ class WebsocketServerInterface(Interface):
|
|||||||
spawned_interface.parent_interface = self
|
spawned_interface.parent_interface = self
|
||||||
spawned_interface.online = True
|
spawned_interface.online = True
|
||||||
|
|
||||||
# todo implement?
|
# TODO implement?
|
||||||
spawned_interface.announce_rate_target = None
|
spawned_interface.announce_rate_target = None
|
||||||
spawned_interface.announce_rate_grace = None
|
spawned_interface.announce_rate_grace = None
|
||||||
spawned_interface.announce_rate_penalty = None
|
spawned_interface.announce_rate_penalty = None
|
||||||
|
|
||||||
# todo ifac?
|
# TODO ifac?
|
||||||
# todo announce rates?
|
# TODO announce rates?
|
||||||
|
|
||||||
# activate child interface
|
# activate child interface
|
||||||
RNS.log(f"Spawned new WebsocketClientInterface: {spawned_interface}", RNS.LOG_VERBOSE)
|
RNS.log(
|
||||||
|
f"Spawned new WebsocketClientInterface: {spawned_interface}",
|
||||||
|
RNS.LOG_VERBOSE,
|
||||||
|
)
|
||||||
RNS.Transport.interfaces.append(spawned_interface)
|
RNS.Transport.interfaces.append(spawned_interface)
|
||||||
|
|
||||||
# associate child interface with this interface
|
# associate child interface with this interface
|
||||||
@@ -126,8 +128,13 @@ class WebsocketServerInterface(Interface):
|
|||||||
|
|
||||||
# run websocket server
|
# run websocket server
|
||||||
try:
|
try:
|
||||||
RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG)
|
RNS.log(f"Starting Websocket server for {self!s}...", RNS.LOG_DEBUG)
|
||||||
with serve(on_websocket_client_connected, self.listen_ip, self.listen_port, compression=None) as server:
|
with serve(
|
||||||
|
on_websocket_client_connected,
|
||||||
|
self.listen_ip,
|
||||||
|
self.listen_port,
|
||||||
|
compression=None,
|
||||||
|
) as server:
|
||||||
self.online = True
|
self.online = True
|
||||||
self.server = server
|
self.server = server
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
@@ -136,12 +143,11 @@ class WebsocketServerInterface(Interface):
|
|||||||
|
|
||||||
# websocket server is no longer running, let's restart it
|
# websocket server is no longer running, let's restart it
|
||||||
self.online = False
|
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)
|
time.sleep(self.RESTART_DELAY_SECONDS)
|
||||||
self.serve()
|
self.serve()
|
||||||
|
|
||||||
def detach(self):
|
def detach(self):
|
||||||
|
|
||||||
# mark as offline
|
# mark as offline
|
||||||
self.online = False
|
self.online = False
|
||||||
|
|
||||||
@@ -152,5 +158,6 @@ class WebsocketServerInterface(Interface):
|
|||||||
# mark as detached
|
# mark as detached
|
||||||
self.detached = True
|
self.detached = True
|
||||||
|
|
||||||
|
|
||||||
# set interface class RNS should use when importing this external interface
|
# set interface class RNS should use when importing this external interface
|
||||||
interface_class = WebsocketServerInterface
|
interface_class = WebsocketServerInterface
|
||||||
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
|
# helper class for passing around an lxmf audio field
|
||||||
class LxmfAudioField:
|
class LxmfAudioField:
|
||||||
|
|
||||||
def __init__(self, audio_mode: int, audio_bytes: bytes):
|
def __init__(self, audio_mode: int, audio_bytes: bytes):
|
||||||
self.audio_mode = audio_mode
|
self.audio_mode = audio_mode
|
||||||
self.audio_bytes = audio_bytes
|
self.audio_bytes = audio_bytes
|
||||||
@@ -11,7 +7,6 @@ class LxmfAudioField:
|
|||||||
|
|
||||||
# helper class for passing around an lxmf image field
|
# helper class for passing around an lxmf image field
|
||||||
class LxmfImageField:
|
class LxmfImageField:
|
||||||
|
|
||||||
def __init__(self, image_type: str, image_bytes: bytes):
|
def __init__(self, image_type: str, image_bytes: bytes):
|
||||||
self.image_type = image_type
|
self.image_type = image_type
|
||||||
self.image_bytes = image_bytes
|
self.image_bytes = image_bytes
|
||||||
@@ -19,7 +14,6 @@ class LxmfImageField:
|
|||||||
|
|
||||||
# helper class for passing around an lxmf file attachment
|
# helper class for passing around an lxmf file attachment
|
||||||
class LxmfFileAttachment:
|
class LxmfFileAttachment:
|
||||||
|
|
||||||
def __init__(self, file_name: str, file_bytes: bytes):
|
def __init__(self, file_name: str, file_bytes: bytes):
|
||||||
self.file_name = file_name
|
self.file_name = file_name
|
||||||
self.file_bytes = file_bytes
|
self.file_bytes = file_bytes
|
||||||
@@ -27,7 +21,5 @@ class LxmfFileAttachment:
|
|||||||
|
|
||||||
# helper class for passing around an lxmf file attachments field
|
# helper class for passing around an lxmf file attachments field
|
||||||
class LxmfFileAttachmentsField:
|
class LxmfFileAttachmentsField:
|
||||||
|
def __init__(self, file_attachments: list[LxmfFileAttachment]):
|
||||||
def __init__(self, file_attachments: List[LxmfFileAttachment]):
|
|
||||||
self.file_attachments = file_attachments
|
self.file_attachments = file_attachments
|
||||||
|
|
||||||
@@ -7,12 +7,6 @@
|
|||||||
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
|
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
|
||||||
<title>Phone | Reticulum MeshChat</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<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>
|
<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 v-if="isPopoutMode" class="flex flex-1 h-full w-full overflow-hidden bg-slate-50/90 dark:bg-zinc-950">
|
||||||
<div class="flex bg-white dark:bg-zinc-950 p-2 border-gray-300 dark:border-zinc-900 border-b min-h-16">
|
<RouterView class="flex-1"/>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- middle -->
|
<template v-else>
|
||||||
<div ref="middle" class="flex h-full w-full overflow-auto">
|
|
||||||
|
|
||||||
<!-- sidebar -->
|
<!-- header -->
|
||||||
<div class="bg-white flex w-72 min-w-72 flex-col dark:bg-zinc-950">
|
<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 grow flex-col overflow-y-auto border-r border-gray-200 bg-white dark:border-zinc-900 dark:bg-zinc-950">
|
<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 -->
|
<!-- onboarding / guidance -->
|
||||||
<div class="flex-1">
|
<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">
|
||||||
<ul class="py-2 pr-2 space-y-1">
|
<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 -->
|
<!-- messages -->
|
||||||
<li>
|
<li>
|
||||||
@@ -144,8 +176,8 @@
|
|||||||
<div>
|
<div>
|
||||||
|
|
||||||
<!-- my identity -->
|
<!-- my identity -->
|
||||||
<div v-if="config" class="bg-white border-t dark:border-zinc-900 dark:bg-zinc-950">
|
<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-2 cursor-pointer">
|
<div @click="isShowingMyIdentitySection = !isShowingMyIdentitySection" class="flex text-gray-700 p-3 cursor-pointer">
|
||||||
<div class="my-auto mr-2">
|
<div class="my-auto mr-2">
|
||||||
<RouterLink @click.stop :to="{ name: 'profile.icon' }">
|
<RouterLink @click.stop :to="{ name: 'profile.icon' }">
|
||||||
<LxmfUserIcon
|
<LxmfUserIcon
|
||||||
@@ -162,8 +194,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 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-1">
|
<div class="p-2">
|
||||||
<input
|
<input
|
||||||
v-model="displayName"
|
v-model="displayName"
|
||||||
type="text"
|
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"
|
dark:bg-zinc-800 dark:border-zinc-600 dark:text-zinc-200 dark:focus:ring-blue-400 dark:focus:border-blue-400"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-1 dark:border-zinc-900">
|
<div class="p-2 dark:border-zinc-900">
|
||||||
<div>Identity Hash</div>
|
<div>Identity Hash</div>
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.identity_hash }}</div>
|
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.identity_hash }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-1 dark:border-zinc-900">
|
<div class="p-2 dark:border-zinc-900">
|
||||||
<div>LXMF Address</div>
|
<div>LXMF Address</div>
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.lxmf_address_hash }}</div>
|
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.lxmf_address_hash }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,8 +216,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- auto announce -->
|
<!-- auto announce -->
|
||||||
<div v-if="config" class="bg-white border-t dark:bg-zinc-950 dark:border-zinc-900">
|
<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-2 cursor-pointer dark:text-white">
|
<div @click="isShowingAnnounceSection = !isShowingAnnounceSection" class="flex text-gray-700 p-3 cursor-pointer dark:text-white">
|
||||||
<div class="my-auto mr-2">
|
<div class="my-auto mr-2">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -214,8 +246,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 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-1 dark:border-zinc-900">
|
<div class="p-2 dark:border-zinc-900">
|
||||||
<select
|
<select
|
||||||
v-model="config.auto_announce_interval_seconds"
|
v-model="config.auto_announce_interval_seconds"
|
||||||
@change="onAnnounceIntervalSecondsChange"
|
@change="onAnnounceIntervalSecondsChange"
|
||||||
@@ -240,8 +272,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- audio calls -->
|
<!-- audio calls -->
|
||||||
<div v-if="config" class="bg-white border-t dark:bg-zinc-950 dark:border-zinc-900">
|
<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-2 cursor-pointer">
|
<div @click="isShowingCallsSection = !isShowingCallsSection" class="flex text-gray-700 p-3 cursor-pointer">
|
||||||
<div class="my-auto mr-2">
|
<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">
|
<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" />
|
<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>
|
||||||
<div class="my-auto dark:text-white">Calls</div>
|
<div class="my-auto dark:text-white">Calls</div>
|
||||||
<div class="ml-auto">
|
<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
|
<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">
|
||||||
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="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" />
|
||||||
<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" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isShowingCallsSection" class="divide-y text-gray-900 border-t border-gray-300 dark:border-zinc-900">
|
<div v-if="isShowingCallsSection" class="divide-y text-gray-900 border-t border-gray-200 dark:border-zinc-900">
|
||||||
<div class="p-1 flex dark:border-zinc-900 dark:text-white">
|
<div class="p-2 flex dark:border-zinc-900 dark:text-white">
|
||||||
<div>
|
<div>
|
||||||
<div>Status</div>
|
<div>Status</div>
|
||||||
<div class="text-sm text-gray-700 dark:text-white">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RouterView/>
|
<RouterView v-if="!isPopoutMode"/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -326,6 +355,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
|
|
||||||
reloadInterval: null,
|
reloadInterval: null,
|
||||||
|
appInfoInterval: null,
|
||||||
|
|
||||||
isShowingMyIdentitySection: true,
|
isShowingMyIdentitySection: true,
|
||||||
isShowingAnnounceSection: true,
|
isShowingAnnounceSection: true,
|
||||||
@@ -343,6 +373,7 @@ export default {
|
|||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
|
|
||||||
clearInterval(this.reloadInterval);
|
clearInterval(this.reloadInterval);
|
||||||
|
clearInterval(this.appInfoInterval);
|
||||||
|
|
||||||
// stop listening for websocket messages
|
// stop listening for websocket messages
|
||||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||||
@@ -354,6 +385,7 @@ export default {
|
|||||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||||
|
|
||||||
this.getAppInfo();
|
this.getAppInfo();
|
||||||
|
this.getConfig();
|
||||||
this.updateCallsList();
|
this.updateCallsList();
|
||||||
this.updatePropagationNodeStatus();
|
this.updatePropagationNodeStatus();
|
||||||
|
|
||||||
@@ -362,9 +394,52 @@ export default {
|
|||||||
this.updateCallsList();
|
this.updateCallsList();
|
||||||
this.updatePropagationNodeStatus();
|
this.updatePropagationNodeStatus();
|
||||||
}, 3000);
|
}, 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: {
|
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) {
|
async onWebsocketMessage(message) {
|
||||||
const json = JSON.parse(message.data);
|
const json = JSON.parse(message.data);
|
||||||
switch(json.type){
|
switch(json.type){
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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/>
|
<slot/>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<div class="flex w-full h-full bg-gray-100 dark:bg-zinc-950" :class="{'dark': config?.theme === 'dark'}">
|
<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-xl p-4">
|
<div class="mx-auto my-auto w-full max-w-2xl p-4 sm:p-6">
|
||||||
|
|
||||||
<!-- in active call -->
|
<!-- in active call -->
|
||||||
<div v-if="isWebsocketConnected" class="w-full">
|
<div v-if="isWebsocketConnected" class="w-full">
|
||||||
<div class="border rounded-xl bg-white shadow w-full">
|
<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 border-b border-gray-300 text-gray-700 p-2">
|
<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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-auto">Active Call</div>
|
<div class="font-semibold text-gray-900 dark:text-zinc-100">Active Call</div>
|
||||||
</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>
|
||||||
<div class="mb-1 text-sm font-medium text-gray-900">Call Hash</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-xs text-gray-600">{{ audioCall?.hash || "Unknown" }}</div>
|
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono">{{ audioCall?.hash || "Unknown" }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-2">
|
<div>
|
||||||
<div class="mb-1 text-sm font-medium text-gray-900">Remote Identity Hash</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-xs text-gray-600">{{ audioCall?.remote_identity_hash || "Unknown" }}</div>
|
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono">{{ audioCall?.remote_identity_hash || "Unknown" }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-2">
|
<div>
|
||||||
<div class="mb-1 text-sm font-medium text-gray-900">Remote Destination Hash</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-xs text-gray-600">{{ audioCall?.remote_destination_hash || "Unknown" }}</div>
|
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono">{{ audioCall?.remote_destination_hash || "Unknown" }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-2">
|
<div>
|
||||||
<div class="mb-1 text-sm font-medium text-gray-900">Path</div>
|
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Path</div>
|
||||||
<div class="text-xs text-gray-600">
|
<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-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>
|
<span v-else>Unknown</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-2">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="mb-1 text-sm font-medium text-gray-900">TX Bytes</div>
|
<div>
|
||||||
<div class="text-xs text-gray-600">{{ formatBytes(txBytes) }}</div>
|
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">TX Bytes</div>
|
||||||
</div>
|
<div class="text-sm text-gray-900 dark:text-zinc-100 font-semibold">{{ formatBytes(txBytes) }}</div>
|
||||||
|
</div>
|
||||||
<div class="mb-2">
|
<div>
|
||||||
<div class="mb-1 text-sm font-medium text-gray-900">RX Bytes</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-xs text-gray-600">{{ formatBytes(rxBytes) }}</div>
|
<div class="text-sm text-gray-900 dark:text-zinc-100 font-semibold">{{ formatBytes(rxBytes) }}</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 text-sm font-medium text-gray-900">Outgoing Audio</div>
|
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Incoming 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="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_3200">Codec2 3200</option>
|
||||||
<option value="MODE_2400">Codec2 2400</option>
|
<option value="MODE_2400">Codec2 2400</option>
|
||||||
<option value="MODE_1600">Codec2 1600</option>
|
<option value="MODE_1600">Codec2 1600</option>
|
||||||
@@ -69,10 +70,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</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 -->
|
<!-- 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">
|
<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>
|
<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>
|
</svg>
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- toggle sound -->
|
<!-- 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -93,7 +94,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- leave call -->
|
<!-- 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -101,7 +102,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- hangup call -->
|
<!-- 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -116,60 +117,56 @@
|
|||||||
<div v-else class="w-full space-y-2">
|
<div v-else class="w-full space-y-2">
|
||||||
|
|
||||||
<!-- dialer -->
|
<!-- dialer -->
|
||||||
<div class="border rounded-xl bg-white shadow w-full overflow-hidden dark:border-zinc-900">
|
<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 border-b border-gray-300 text-gray-700 p-2 dark:bg-zinc-800 dark:text-white">
|
<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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</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>
|
||||||
<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="p-4 space-y-3">
|
||||||
<div class="flex-1">
|
<div class="flex gap-2">
|
||||||
<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">
|
<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>
|
</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>
|
||||||
<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>
|
||||||
<div class='dark:text-white'>My Destination Hash</div>
|
<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-700 dark:text-zinc-100">{{ myAudioCallAddressHash || "Unknown" }}</div>
|
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono mt-0.5">{{ 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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- active calls -->
|
<!-- 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 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 border-b border-gray-300 text-gray-700 p-2 dark:text-zinc-100">
|
<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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-auto">Active Calls</div>
|
<div class="font-semibold text-gray-900 dark:text-zinc-100">Active Calls</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y">
|
<div class="divide-y divide-gray-200 dark:divide-zinc-800">
|
||||||
<div v-for="audioCall in activeAudioCalls" class="flex p-2">
|
<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-2 my-auto">
|
<div class="mr-3 flex-shrink-0">
|
||||||
<div class="bg-gray-100 p-2 rounded-full">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -178,24 +175,24 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex-1 min-w-0">
|
||||||
<div>{{ audioCall.remote_destination_hash || "Unknown" }}</div>
|
<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-100">
|
<div class="text-sm text-gray-500 dark:text-zinc-400">
|
||||||
<span v-if="audioCall.is_outbound">Outgoing Call...</span>
|
<span v-if="audioCall.is_outbound">Outgoing Call...</span>
|
||||||
<span v-else>Incoming Call...</span>
|
<span v-else>Incoming Call...</span>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- hangup call -->
|
<!-- 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -207,24 +204,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- call history -->
|
<!-- 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 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 border-b border-gray-300 text-gray-700 p-2">
|
<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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-auto">Call History</div>
|
<div class="font-semibold text-gray-900 dark:text-zinc-100">Call History</div>
|
||||||
<div class="ml-auto">
|
<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">
|
||||||
<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
|
||||||
Clear All
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y">
|
<div class="divide-y divide-gray-200 dark:divide-zinc-800">
|
||||||
<div v-for="audioCall in inactiveAudioCalls" class="group flex p-2">
|
<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-2 my-auto">
|
<div class="mr-3 flex-shrink-0">
|
||||||
<div class="bg-gray-100 p-2 rounded-full">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -233,14 +228,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex-1 min-w-0">
|
||||||
<div>Destination: {{ audioCall.remote_destination_hash || "Unknown" }}</div>
|
<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">Call Hash: {{ audioCall.hash }}</div>
|
<div class="text-sm text-gray-500 dark:text-zinc-400 font-mono">Call Hash: {{ audioCall.hash }}</div>
|
||||||
</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 -->
|
<!-- 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
<template>
|
<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 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-2 space-y-2">
|
<div class="overflow-y-auto p-3 md:p-6 space-y-4 max-w-5xl mx-auto w-full">
|
||||||
|
|
||||||
<!-- community interfaces -->
|
<!-- 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 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-2">
|
<div class="flex p-3">
|
||||||
<div class="my-auto mr-auto">
|
<div class="my-auto mr-auto">
|
||||||
<div class="font-bold dark:text-white">Community Interfaces</div>
|
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Quick start</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="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>
|
||||||
<div class="my-auto ml-2">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
@@ -19,31 +20,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-200 dark:text-white">
|
<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 class="my-auto mr-auto">
|
||||||
<div>RNS Testnet Amsterdam</div>
|
<div class="font-semibold text-gray-900 dark:text-gray-100">RNS Testnet Amsterdam</div>
|
||||||
<div class="text-xs">amsterdam.connect.reticulum.network:4965</div>
|
<div class="text-xs text-gray-600 dark:text-gray-300">amsterdam.connect.reticulum.network:4965</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-2 my-auto">
|
<div class="ml-2 my-auto">
|
||||||
<button
|
<button
|
||||||
@click="newInterfaceName='RNS Testnet Amsterdam';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='amsterdam.connect.reticulum.network';newInterfaceTargetPort='4965'"
|
@click="newInterfaceName='RNS Testnet Amsterdam';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='amsterdam.connect.reticulum.network';newInterfaceTargetPort='4965'"
|
||||||
type="button"
|
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>
|
<span>Use Interface</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 class="my-auto mr-auto">
|
||||||
<div>RNS Testnet BetweenTheBorders</div>
|
<div class="font-semibold text-gray-900 dark:text-gray-100">RNS Testnet BetweenTheBorders</div>
|
||||||
<div class="text-xs">reticulum.betweentheborders.com:4242</div>
|
<div class="text-xs text-gray-600 dark:text-gray-300">reticulum.betweentheborders.com:4242</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-2 my-auto">
|
<div class="ml-2 my-auto">
|
||||||
<button
|
<button
|
||||||
@click="newInterfaceName='RNS Testnet BetweenTheBorders';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='reticulum.betweentheborders.com';newInterfaceTargetPort='4242'"
|
@click="newInterfaceName='RNS Testnet BetweenTheBorders';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='reticulum.betweentheborders.com';newInterfaceTargetPort='4242'"
|
||||||
type="button"
|
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>
|
<span>Use Interface</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,44 +54,64 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- add interface form -->
|
<!-- add interface form -->
|
||||||
<div class="bg-white rounded shadow divide-y divide-gray-300 dark:divide-zinc-700 dark:bg-zinc-900">
|
<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="p-2 font-bold dark:text-white">
|
<div class="flex flex-wrap gap-3 items-center p-3 border-b border-gray-200 dark:border-zinc-800">
|
||||||
<span v-if="isEditingInterface">Edit Interface</span>
|
<div>
|
||||||
<span v-else>Add Interface</span>
|
<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>
|
||||||
<div class="p-2 space-y-3">
|
<div class="p-3 md:p-5 space-y-4">
|
||||||
|
|
||||||
<!-- iGeneric interface settings -->
|
<!-- iGeneric interface settings -->
|
||||||
<!-- interface name -->
|
<!-- interface name -->
|
||||||
<div>
|
<div>
|
||||||
<FormLabel class="mb-1">Name</FormLabel>
|
<FormLabel class="glass-label">Name</FormLabel>
|
||||||
<input type="text" :disabled="isEditingInterface" placeholder="New Interface Name"
|
<input type="text" :disabled="isEditingInterface" placeholder="New Interface Name"
|
||||||
v-model="newInterfaceName"
|
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="input-field"
|
||||||
:class="[ isEditingInterface ? 'cursor-not-allowed bg-gray-200' : 'bg-gray-50' ]">
|
:class="[ isEditingInterface ? 'cursor-not-allowed bg-gray-200 dark:bg-zinc-800' : '' ]">
|
||||||
<FormSubLabel>Interface names must be unique.</FormSubLabel>
|
<FormSubLabel class="text-xs">Interface names must be unique.</FormSubLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- interface type -->
|
<!-- interface type -->
|
||||||
<div class="mb-2">
|
<div>
|
||||||
<FormLabel class="mb-1">Type</FormLabel>
|
<FormLabel class="glass-label">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">
|
<select v-model="newInterfaceType" class="input-field">
|
||||||
<option disabled selected>--</option>
|
<option disabled selected>Pick a category…</option>
|
||||||
<option value="AutoInterface">Auto Interface</option>
|
<optgroup label="Automatic">
|
||||||
<option disabled selected>RNodes</option>
|
<option value="AutoInterface">Auto Interface</option>
|
||||||
<option value="RNodeInterface">RNode Interface</option>
|
</optgroup>
|
||||||
<option value="RNodeMultiInterface">RNode Multi Interface</option>
|
<optgroup label="RNodes">
|
||||||
<option disabled selected>IP Networks</option>
|
<option value="RNodeInterface">RNode Interface</option>
|
||||||
<option value="TCPClientInterface">TCP Client Interface</option>
|
<option value="RNodeMultiInterface">RNode Multi Interface</option>
|
||||||
<option value="TCPServerInterface">TCP Server Interface</option>
|
</optgroup>
|
||||||
<option value="UDPInterface">UDP Interface</option>
|
<optgroup label="IP Networks">
|
||||||
<option value="I2PInterface">I2P Interface</option>
|
<option value="TCPClientInterface">TCP Client Interface</option>
|
||||||
<option disabled selected>Hardware</option>
|
<option value="TCPServerInterface">TCP Server Interface</option>
|
||||||
<option value="SerialInterface">Serial Interface</option>
|
<option value="UDPInterface">UDP Interface</option>
|
||||||
<option value="KISSInterface">KISS Interface</option>
|
<option value="I2PInterface">I2P Interface</option>
|
||||||
<option hidden value="AX25KISSInterface">AX.25 KISS Interface</option>
|
</optgroup>
|
||||||
<option disabled selected>Other</option>
|
<optgroup label="Hardware">
|
||||||
<option value="PipeInterface">Pipe Interface</option>
|
<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>
|
</select>
|
||||||
<FormSubLabel>
|
<FormSubLabel>
|
||||||
Need help? <a class="text-blue-500 underline" href="https://reticulum.network/manual/interfaces.html" target="_blank">Reticulum Docs: Configuring Interfaces</a>
|
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 -->
|
<!-- interface target host -->
|
||||||
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
|
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
|
||||||
<FormLabel class="mb-1">Target Host</FormLabel>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- interface target port -->
|
<!-- interface target port -->
|
||||||
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
|
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
|
||||||
<FormLabel class="mb-1">Target Port</FormLabel>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- TCPServerInterface -->
|
<!-- TCPServerInterface -->
|
||||||
@@ -1317,3 +1338,24 @@ export default {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</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.importableInterfaces = [];
|
||||||
this.selectedInterfaces = [];
|
this.selectedInterfaces = [];
|
||||||
},
|
},
|
||||||
dismiss() {
|
dismiss(result = false) {
|
||||||
this.isShowing = false;
|
this.isShowing = false;
|
||||||
this.$emit("dismissed");
|
const imported = result === true;
|
||||||
|
this.$emit("dismissed", imported);
|
||||||
},
|
},
|
||||||
clearSelectedFile() {
|
clearSelectedFile() {
|
||||||
this.selectedFile = null;
|
this.selectedFile = null;
|
||||||
@@ -221,7 +222,7 @@ export default {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// dismiss modal
|
// dismiss modal
|
||||||
this.dismiss();
|
this.dismiss(true);
|
||||||
|
|
||||||
// tell user interfaces were imported
|
// tell user interfaces were imported
|
||||||
DialogUtils.alert("Interfaces imported successfully. MeshChat must be restarted for these changes to take effect.");
|
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>
|
<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">
|
<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">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
|
<MaterialDesignIcon icon-name="microphone" class="w-4 h-4"/>
|
||||||
<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">
|
<span class="ml-1">
|
||||||
<slot/>
|
<slot/>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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">
|
<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">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
|
<MaterialDesignIcon icon-name="microphone-plus" class="w-4 h-4"/>
|
||||||
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
|
<span class="hidden xl:inline-block whitespace-nowrap">Add Voice</span>
|
||||||
<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>
|
</button>
|
||||||
|
|
||||||
<div class="relative block">
|
<div class="relative block">
|
||||||
@@ -27,11 +21,11 @@
|
|||||||
leave-active-class="transition ease-in duration-75"
|
leave-active-class="transition ease-in duration-75"
|
||||||
leave-from-class="transform opacity-100 scale-100"
|
leave-from-class="transform opacity-100 scale-100"
|
||||||
leave-to-class="transform opacity-0 scale-95">
|
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">
|
<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('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 hover:bg-gray-100 whitespace-nowrap">Medium Quality - Codec2 (3200)</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 hover:bg-gray-100 whitespace-nowrap">High Quality - OPUS</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>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -41,8 +35,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
export default {
|
export default {
|
||||||
name: 'AddAudioButton',
|
name: 'AddAudioButton',
|
||||||
|
components: {
|
||||||
|
MaterialDesignIcon,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
isRecordingAudioAttachment: Boolean,
|
isRecordingAudioAttachment: Boolean,
|
||||||
},
|
},
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
<template>
|
<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">
|
<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">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
<MaterialDesignIcon icon-name="image-plus" class="w-4 h-4"/>
|
||||||
<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" />
|
<span class="hidden xl:inline-block whitespace-nowrap">Add Image</span>
|
||||||
</svg>
|
|
||||||
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Image</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="relative block">
|
<div class="relative block">
|
||||||
@@ -16,12 +14,12 @@
|
|||||||
leave-active-class="transition ease-in duration-75"
|
leave-active-class="transition ease-in duration-75"
|
||||||
leave-from-class="transform opacity-100 scale-100"
|
leave-from-class="transform opacity-100 scale-100"
|
||||||
leave-to-class="transform opacity-0 scale-95">
|
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">
|
<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('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 hover:bg-gray-100 whitespace-nowrap">Medium Quality (640x640)</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 hover:bg-gray-100 whitespace-nowrap">High Quality (1280x1280)</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 hover:bg-gray-100 whitespace-nowrap">Original Quality</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>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -36,8 +34,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import Compressor from 'compressorjs';
|
import Compressor from 'compressorjs';
|
||||||
import DialogUtils from "../../js/DialogUtils";
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
export default {
|
export default {
|
||||||
name: 'AddImageButton',
|
name: 'AddImageButton',
|
||||||
|
components: {
|
||||||
|
MaterialDesignIcon,
|
||||||
|
},
|
||||||
emits: [
|
emits: [
|
||||||
"add-image",
|
"add-image",
|
||||||
],
|
],
|
||||||
@@ -35,6 +35,20 @@
|
|||||||
<span>Set Custom Display Name</span>
|
<span>Set Custom Display Name</span>
|
||||||
</DropDownMenuItem>
|
</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 -->
|
<!-- delete message history button -->
|
||||||
<div class="border-t">
|
<div class="border-t">
|
||||||
<DropDownMenuItem @click="onDeleteMessageHistory">
|
<DropDownMenuItem @click="onDeleteMessageHistory">
|
||||||
@@ -53,6 +67,7 @@
|
|||||||
import DropDownMenu from "../DropDownMenu.vue";
|
import DropDownMenu from "../DropDownMenu.vue";
|
||||||
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||||
import IconButton from "../IconButton.vue";
|
import IconButton from "../IconButton.vue";
|
||||||
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
import DialogUtils from "../../js/DialogUtils";
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -61,6 +76,7 @@ export default {
|
|||||||
IconButton,
|
IconButton,
|
||||||
DropDownMenuItem,
|
DropDownMenuItem,
|
||||||
DropDownMenu,
|
DropDownMenu,
|
||||||
|
MaterialDesignIcon,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
peer: Object,
|
peer: Object,
|
||||||
@@ -68,8 +84,72 @@ export default {
|
|||||||
emits: [
|
emits: [
|
||||||
"conversation-deleted",
|
"conversation-deleted",
|
||||||
"set-custom-display-name",
|
"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: {
|
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() {
|
async onDeleteMessageHistory() {
|
||||||
|
|
||||||
// ask user to confirm deleting conversation history
|
// ask user to confirm deleting conversation history
|
||||||
@@ -1,33 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
|
|
||||||
<!-- peer selected -->
|
<!-- 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 -->
|
<!-- 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 -->
|
<!-- peer icon -->
|
||||||
<div class="my-auto mr-2">
|
<div class="flex-shrink-0 mr-3">
|
||||||
<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 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"/>
|
<MaterialDesignIcon :icon-name="selectedPeer.lxmf_user_icon.icon_name" class="w-6 h-6"/>
|
||||||
</div>
|
</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"/>
|
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- peer info -->
|
<!-- peer info -->
|
||||||
<div>
|
<div class="min-w-0 flex-1">
|
||||||
<div @click="updateCustomDisplayName" class="flex cursor-pointer">
|
<div @click="updateCustomDisplayName" class="flex items-center cursor-pointer min-w-0 group">
|
||||||
<div v-if="selectedPeer.custom_display_name != null" class="my-auto mr-1 dark:text-white" title="Custom Display Name">
|
<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="size-4">
|
<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="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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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>
|
||||||
<div class="text-sm dark:text-zinc-300">
|
<div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
|
||||||
|
|
||||||
<!-- destination hash -->
|
<!-- destination hash -->
|
||||||
<div class="inline-block mr-1">
|
<div class="inline-block mr-1">
|
||||||
@@ -62,18 +64,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- dropdown menu -->
|
<!-- dropdown menu -->
|
||||||
<div class="ml-auto my-auto mx-2">
|
<div class="ml-auto flex items-center gap-1">
|
||||||
<ConversationDropDownMenu
|
<ConversationDropDownMenu
|
||||||
v-if="selectedPeer"
|
v-if="selectedPeer"
|
||||||
:peer="selectedPeer"
|
:peer="selectedPeer"
|
||||||
@conversation-deleted="onConversationDeleted"
|
@conversation-deleted="onConversationDeleted"
|
||||||
@set-custom-display-name="updateCustomDisplayName"/>
|
@set-custom-display-name="updateCustomDisplayName"
|
||||||
</div>
|
@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 -->
|
<!-- close button -->
|
||||||
<div class="my-auto mr-2">
|
<IconButton @click="close" class="text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-200">
|
||||||
<IconButton @click="close">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -82,37 +88,59 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- chat items -->
|
<!-- 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 -->
|
<!-- 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 -->
|
<!-- 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 -->
|
<!-- image field -->
|
||||||
<div v-if="chatItem.lxmf_message.fields?.image">
|
<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-md cursor-pointer"/>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- audio field -->
|
<!-- audio field -->
|
||||||
<div v-if="chatItem.lxmf_message.fields?.audio" class="pb-1">
|
<div v-if="chatItem.lxmf_message.fields?.audio" class="pb-1">
|
||||||
|
|
||||||
<!-- audio is loaded -->
|
<!-- audio is loaded -->
|
||||||
<audio v-if="lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" controls class="shadow rounded-full" style="height:54px;">
|
<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>
|
||||||
<source :src="lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" type="audio/wav"/>
|
|
||||||
</audio>
|
|
||||||
|
|
||||||
<!-- audio is not yet loaded -->
|
<!-- audio is not yet loaded -->
|
||||||
<!-- min height to make sure audio player doesn't cause height increase after loading -->
|
<!-- min height to make sure audio player doesn't cause height increase after loading -->
|
||||||
<div v-else style="min-height:54px;" class="flex">
|
<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">
|
<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">
|
<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" />
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- file attachment fields -->
|
<!-- file attachment fields -->
|
||||||
<div v-if="chatItem.lxmf_message.fields?.file_attachments" class="space-y-1">
|
<div v-if="chatItem.lxmf_message.fields?.file_attachments" class="space-y-2 mt-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]">
|
<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">
|
<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">
|
<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>
|
<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>
|
</svg>
|
||||||
</div>
|
</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">
|
<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">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- actions -->
|
<!-- 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 -->
|
<!-- 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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -163,12 +204,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- message state -->
|
<!-- 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 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 space-x-1">
|
<div class="flex ml-auto items-center space-x-1.5 text-xs">
|
||||||
|
|
||||||
<!-- state label -->
|
<!-- state label -->
|
||||||
<div class="my-auto">
|
<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>{{ 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 === '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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- inbound message info -->
|
<!-- 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 -->
|
<!-- 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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- load previous -->
|
<!-- 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">
|
<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="size-6">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span>Load Previous</span>
|
<span>Load Previous</span>
|
||||||
@@ -233,83 +279,60 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- send message -->
|
<!-- send message -->
|
||||||
<div class="w-full border-gray-300 dark:border-zinc-800 border-t p-2">
|
<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="mx-auto">
|
<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 -->
|
<!-- message composer -->
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
<!-- image attachment -->
|
<div class="space-y-2">
|
||||||
<div v-if="newMessageImage" class="mb-2">
|
<!-- image attachment -->
|
||||||
<div @click.stop="openImage(newMessageImageUrl)" class="cursor-pointer w-32 h-32 rounded shadow border relative overflow-hidden">
|
<div v-if="newMessageImage" class="attachment-card">
|
||||||
|
<div class="attachment-card__preview" @click.stop="openImage(newMessageImageUrl)">
|
||||||
<!-- image preview -->
|
<img v-if="newMessageImageUrl" :src="newMessageImageUrl" class="w-full h-full object-cover rounded-lg"/>
|
||||||
<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>
|
</div>
|
||||||
|
<div class="attachment-card__body">
|
||||||
<!-- image size (bottom left) -->
|
<div class="attachment-card__title">Image Attachment</div>
|
||||||
<div class="absolute bottom-0 left-0 p-1">
|
<div class="attachment-card__meta">{{ formatBytes(newMessageImage.size) }}</div>
|
||||||
<div class="bg-gray-100 rounded border text-sm px-1">{{ formatBytes(newMessageImage.size) }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button @click.stop="removeImageAttachment" type="button" class="attachment-card__remove">
|
||||||
|
<MaterialDesignIcon icon-name="close" class="w-4 h-4"/>
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
<button @click="removeAudioAttachment" type="button" class="attachment-card__remove">
|
||||||
|
<MaterialDesignIcon icon-name="delete" class="w-4 h-4"/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- file attachments -->
|
<!-- file attachments -->
|
||||||
<div v-if="newMessageFiles.length > 0" class="mb-2">
|
<div v-if="newMessageFiles.length > 0" class="flex flex-wrap gap-2">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div v-for="file in newMessageFiles" :key="file.name + file.size" class="attachment-chip">
|
||||||
<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="flex items-center gap-2">
|
||||||
<div class="my-auto px-1">
|
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4 text-gray-500 dark:text-gray-300"/>
|
||||||
<span class="mr-1">{{ file.name }}</span>
|
<div class="text-sm text-gray-800 dark:text-gray-200 truncate max-w-[160px]">{{ file.name }}</div>
|
||||||
<span class="my-auto text-sm text-gray-500">{{ formatBytes(file.size) }}</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ 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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,38 +345,23 @@
|
|||||||
v-model="newMessageText"
|
v-model="newMessageText"
|
||||||
@keydown.enter.exact.native.prevent="onEnterPressed"
|
@keydown.enter.exact.native.prevent="onEnterPressed"
|
||||||
@keydown.enter.shift.exact.native.prevent="onShiftEnterPressed"
|
@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"
|
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="3"
|
rows="2"
|
||||||
placeholder="Send a message..."></textarea>
|
placeholder="Type a message..."></textarea>
|
||||||
|
|
||||||
<!-- action button -->
|
<!-- action button -->
|
||||||
<div class="flex mt-2">
|
<div class="flex flex-wrap gap-2 items-center mt-2">
|
||||||
|
<button @click="addFilesToMessage" type="button" class="attachment-action-button">
|
||||||
<!-- add files -->
|
<MaterialDesignIcon icon-name="paperclip-plus" class="w-4 h-4"/>
|
||||||
<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">
|
<span>Add Files</span>
|
||||||
<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>
|
|
||||||
</button>
|
</button>
|
||||||
|
<AddImageButton @add-image="onImageSelected"/>
|
||||||
<!-- add image -->
|
<AddAudioButton
|
||||||
<div>
|
:is-recording-audio-attachment="isRecordingAudioAttachment"
|
||||||
<AddImageButton @add-image="onImageSelected"/>
|
@start-recording="startRecordingAudioAttachment($event)"
|
||||||
</div>
|
@stop-recording="stopRecordingAudioAttachment">
|
||||||
|
<span>Recording: {{ audioAttachmentRecordingDuration }}</span>
|
||||||
<!-- add audio -->
|
</AddAudioButton>
|
||||||
<div>
|
|
||||||
<AddAudioButton
|
|
||||||
:is-recording-audio-attachment="isRecordingAudioAttachment"
|
|
||||||
@start-recording="startRecordingAudioAttachment($event)"
|
|
||||||
@stop-recording="stopRecordingAudioAttachment">
|
|
||||||
<span>Recording: {{ audioAttachmentRecordingDuration }}</span>
|
|
||||||
</AddAudioButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- send message -->
|
|
||||||
<div class="ml-auto my-auto">
|
<div class="ml-auto my-auto">
|
||||||
<SendMessageButton
|
<SendMessageButton
|
||||||
@send="sendMessage"
|
@send="sendMessage"
|
||||||
@@ -362,7 +370,6 @@
|
|||||||
:can-send-message="canSendMessage"
|
:can-send-message="canSendMessage"
|
||||||
:delivery-method="newMessageDeliveryMethod"/>
|
:delivery-method="newMessageDeliveryMethod"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -376,23 +383,53 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- no peer selected -->
|
<!-- no peer selected -->
|
||||||
<div v-else class="flex flex-col mx-auto my-auto text-center leading-5">
|
<div v-else class="flex flex-col h-full items-center justify-center">
|
||||||
<div class="mx-auto mb-1 ">
|
<div class="w-full max-w-md px-4">
|
||||||
<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">
|
<div class="mb-6 text-center">
|
||||||
<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" />
|
<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>
|
<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">
|
||||||
</div>
|
<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" />
|
||||||
<div class="font-semibold dark:text-white">No Active Chat</div>
|
</svg>
|
||||||
<div class='dark:text-zinc-300'>Select a Peer to start chatting!</div>
|
</div>
|
||||||
<div class="mx-auto mt-2">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-zinc-100 mb-1">No Active Chat</h3>
|
||||||
<button @click.stop="openLXMFAddress" type="button"
|
<p class="text-sm text-gray-500 dark:text-zinc-400">Select a peer from the sidebar or enter an address below</p>
|
||||||
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
|
</div>
|
||||||
dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
|
||||||
Enter an LXMF Address
|
<!-- compose message input -->
|
||||||
</button>
|
<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>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -446,6 +483,7 @@ export default {
|
|||||||
newMessageFiles: [],
|
newMessageFiles: [],
|
||||||
isSendingMessage: false,
|
isSendingMessage: false,
|
||||||
autoScrollOnNewMessage: true,
|
autoScrollOnNewMessage: true,
|
||||||
|
composeAddress: "",
|
||||||
|
|
||||||
isRecordingAudioAttachment: false,
|
isRecordingAudioAttachment: false,
|
||||||
audioAttachmentMicrophoneRecorder: null,
|
audioAttachmentMicrophoneRecorder: null,
|
||||||
@@ -454,6 +492,10 @@ export default {
|
|||||||
audioAttachmentRecordingDuration: null,
|
audioAttachmentRecordingDuration: null,
|
||||||
audioAttachmentRecordingTimer: null,
|
audioAttachmentRecordingTimer: null,
|
||||||
lxmfMessageAudioAttachmentCache: {},
|
lxmfMessageAudioAttachmentCache: {},
|
||||||
|
expandedMessageInfo: null,
|
||||||
|
imageModalUrl: null,
|
||||||
|
isSelectedPeerBlocked: false,
|
||||||
|
blockedDestinations: [],
|
||||||
lxmfAudioModeToCodec2ModeMap: {
|
lxmfAudioModeToCodec2ModeMap: {
|
||||||
// https://github.com/markqvist/LXMF/blob/master/LXMF/LXMF.py#L21
|
// https://github.com/markqvist/LXMF/blob/master/LXMF/LXMF.py#L21
|
||||||
0x01: "450PWB", // AM_CODEC2_450PWB
|
0x01: "450PWB", // AM_CODEC2_450PWB
|
||||||
@@ -472,14 +514,47 @@ export default {
|
|||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
// stop listening for websocket messages
|
// stop listening for websocket messages
|
||||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||||
|
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
selectedPeer: {
|
||||||
|
handler() {
|
||||||
|
this.checkIfSelectedPeerBlocked();
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
||||||
// listen for websocket messages
|
// listen for websocket messages
|
||||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||||
|
|
||||||
|
// listen for compose new message event
|
||||||
|
GlobalEmitter.on("compose-new-message", this.onComposeNewMessageEvent);
|
||||||
|
|
||||||
|
// load blocked destinations
|
||||||
|
this.loadBlockedDestinations();
|
||||||
|
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
close() {
|
||||||
this.$emit("close");
|
this.$emit("close");
|
||||||
},
|
},
|
||||||
@@ -614,6 +689,37 @@ export default {
|
|||||||
openLXMFAddress() {
|
openLXMFAddress() {
|
||||||
GlobalEmitter.emit("compose-new-message");
|
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) {
|
onLxmfMessageReceived(lxmfMessage) {
|
||||||
|
|
||||||
// add inbound message to ui
|
// add inbound message to ui
|
||||||
@@ -862,16 +968,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
openImage: async function(url) {
|
openImage: async function(url) {
|
||||||
|
this.imageModalUrl = url;
|
||||||
// convert data uri to blob
|
},
|
||||||
const blob = await (await fetch(url)).blob();
|
closeImageModal() {
|
||||||
|
this.imageModalUrl = null;
|
||||||
// create blob url
|
|
||||||
const fileUrl = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
// open new tab
|
|
||||||
window.open(fileUrl);
|
|
||||||
|
|
||||||
},
|
},
|
||||||
downloadFileFromBase64: async function(fileName, fileBytesBase64) {
|
downloadFileFromBase64: async function(fileName, fileBytesBase64) {
|
||||||
|
|
||||||
@@ -1205,6 +1305,23 @@ export default {
|
|||||||
formatBytes: function(bytes) {
|
formatBytes: function(bytes) {
|
||||||
return Utils.formatBytes(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) {
|
onFileInputChange: function(event) {
|
||||||
for(const file of event.target.files){
|
for(const file of event.target.files){
|
||||||
this.newMessageFiles.push(file);
|
this.newMessageFiles.push(file);
|
||||||
@@ -1464,89 +1581,64 @@ export default {
|
|||||||
this.$emit("reload-conversations");
|
this.$emit("reload-conversations");
|
||||||
|
|
||||||
},
|
},
|
||||||
showSentMessageInfo: function(lxmfMessage) {
|
toggleSentMessageInfo: function(messageHash) {
|
||||||
|
if(this.expandedMessageInfo === messageHash){
|
||||||
// basic info
|
this.expandedMessageInfo = null;
|
||||||
const info = [
|
} else {
|
||||||
`Created: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`,
|
this.expandedMessageInfo = messageHash;
|
||||||
`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)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
if(isOutbound){
|
||||||
const info = [
|
lines.push(`Created: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`);
|
||||||
`Sent: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`,
|
} else {
|
||||||
`Received: ${Utils.convertDateTimeToLocalDateTimeString(new Date(lxmfMessage.created_at))}`,
|
lines.push(`Sent: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`);
|
||||||
`Method: ${lxmfMessage.method ?? "unknown"}`,
|
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){
|
if(lxmfMessage.fields?.audio?.audio_bytes){
|
||||||
const audioBytesLength = atob(lxmfMessage.fields?.audio?.audio_bytes).length;
|
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){
|
if(lxmfMessage.fields?.image?.image_bytes){
|
||||||
const imageBytesLength = atob(lxmfMessage.fields?.image?.image_bytes).length;
|
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){
|
if(lxmfMessage.fields?.file_attachments){
|
||||||
var filesLength = 0;
|
var filesLength = 0;
|
||||||
for(const fileAttachment of lxmfMessage.fields?.file_attachments){
|
for(const fileAttachment of lxmfMessage.fields?.file_attachments){
|
||||||
const fileBytesLength = atob(fileAttachment.file_bytes).length;
|
const fileBytesLength = atob(fileAttachment.file_bytes).length;
|
||||||
filesLength += fileBytesLength;
|
filesLength += fileBytesLength;
|
||||||
}
|
}
|
||||||
info.push(`File Attachments: ${this.formatBytes(filesLength)}`);
|
lines.push(`File Attachments: ${this.formatBytes(filesLength)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// add signal quality if available
|
if(!isOutbound){
|
||||||
if(lxmfMessage.quality != null){
|
if(lxmfMessage.quality != null){
|
||||||
info.push(`Signal Quality: ${lxmfMessage.quality}%`);
|
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
|
return lines;
|
||||||
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"));
|
|
||||||
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -1618,3 +1710,57 @@ export default {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
<template>
|
||||||
|
|
||||||
<MessagesSidebar
|
<MessagesSidebar
|
||||||
|
v-if="!isPopoutMode"
|
||||||
:conversations="conversations"
|
:conversations="conversations"
|
||||||
:peers="peers"
|
:peers="peers"
|
||||||
:selected-destination-hash="selectedPeer?.destination_hash"
|
: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"
|
@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 -->
|
<!-- messages tab -->
|
||||||
<ConversationViewer
|
<ConversationViewer
|
||||||
@@ -45,6 +53,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
|
|
||||||
reloadInterval: null,
|
reloadInterval: null,
|
||||||
|
conversationRefreshTimeout: null,
|
||||||
|
|
||||||
config: null,
|
config: null,
|
||||||
peers: {},
|
peers: {},
|
||||||
@@ -53,11 +62,18 @@ export default {
|
|||||||
conversations: [],
|
conversations: [],
|
||||||
lxmfDeliveryAnnounces: [],
|
lxmfDeliveryAnnounces: [],
|
||||||
|
|
||||||
|
conversationSearchTerm: "",
|
||||||
|
filterUnreadOnly: false,
|
||||||
|
filterFailedOnly: false,
|
||||||
|
filterHasAttachmentsOnly: false,
|
||||||
|
isLoadingConversations: false,
|
||||||
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
|
|
||||||
clearInterval(this.reloadInterval);
|
clearInterval(this.reloadInterval);
|
||||||
|
clearTimeout(this.conversationRefreshTimeout);
|
||||||
|
|
||||||
// stop listening for websocket messages
|
// stop listening for websocket messages
|
||||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||||
@@ -87,37 +103,36 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async onComposeNewMessage(destinationHash) {
|
async onComposeNewMessage(destinationHash) {
|
||||||
|
|
||||||
// ask for destination address if not provided
|
|
||||||
if(destinationHash == null){
|
if(destinationHash == null){
|
||||||
destinationHash = await DialogUtils.prompt("Enter LXMF Address");
|
if(this.selectedPeer){
|
||||||
if(!destinationHash){
|
|
||||||
return;
|
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@")){
|
if(destinationHash.startsWith("lxmf@")){
|
||||||
destinationHash = destinationHash.replace("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);
|
await this.getLxmfDeliveryAnnounce(destinationHash);
|
||||||
|
|
||||||
// attempt to find existing peer so we can show their name
|
|
||||||
const existingPeer = this.peers[destinationHash];
|
const existingPeer = this.peers[destinationHash];
|
||||||
if(existingPeer){
|
if(existingPeer){
|
||||||
this.onPeerClick(existingPeer);
|
this.onPeerClick(existingPeer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// simple attempt to prevent garbage input
|
|
||||||
if(destinationHash.length !== 32){
|
if(destinationHash.length !== 32){
|
||||||
DialogUtils.alert("Invalid Address");
|
DialogUtils.alert("Invalid Address");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// we didn't find an existing peer, so just use an unknown name
|
|
||||||
this.onPeerClick({
|
this.onPeerClick({
|
||||||
display_name: "Unknown Peer",
|
display_name: "Unknown Peer",
|
||||||
destination_hash: destinationHash,
|
destination_hash: destinationHash,
|
||||||
@@ -200,13 +215,34 @@ export default {
|
|||||||
},
|
},
|
||||||
async getConversations() {
|
async getConversations() {
|
||||||
try {
|
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;
|
this.conversations = response.data.conversations;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
// do nothing if failed to load conversations
|
// do nothing if failed to load conversations
|
||||||
console.log(e);
|
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) {
|
updatePeerFromAnnounce: function(announce) {
|
||||||
this.peers[announce.destination_hash] = announce;
|
this.peers[announce.destination_hash] = announce;
|
||||||
},
|
},
|
||||||
@@ -216,12 +252,17 @@ export default {
|
|||||||
this.selectedPeer = peer;
|
this.selectedPeer = peer;
|
||||||
|
|
||||||
// update current route
|
// update current route
|
||||||
this.$router.replace({
|
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
|
||||||
name: "messages",
|
const routeOptions = {
|
||||||
|
name: routeName,
|
||||||
params: {
|
params: {
|
||||||
destinationHash: peer.destination_hash,
|
destinationHash: peer.destination_hash,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
if(!this.isPopoutMode && this.$route?.query){
|
||||||
|
routeOptions.query = { ...this.$route.query };
|
||||||
|
}
|
||||||
|
this.$router.replace(routeOptions);
|
||||||
|
|
||||||
},
|
},
|
||||||
onConversationClick: function(conversation) {
|
onConversationClick: function(conversation) {
|
||||||
@@ -238,11 +279,57 @@ export default {
|
|||||||
// clear selected peer
|
// clear selected peer
|
||||||
this.selectedPeer = null;
|
this.selectedPeer = null;
|
||||||
|
|
||||||
// update current route
|
if(this.isPopoutMode){
|
||||||
this.$router.replace({
|
window.close();
|
||||||
name: "messages",
|
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: {
|
watch: {
|
||||||
@@ -2,25 +2,40 @@
|
|||||||
<div class="flex flex-col w-80 min-w-80">
|
<div class="flex flex-col w-80 min-w-80">
|
||||||
|
|
||||||
<!-- tabs -->
|
<!-- 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 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 = '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-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 = '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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- conversations -->
|
<!-- 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 -->
|
<!-- search + filters -->
|
||||||
<div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
|
<div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-2">
|
||||||
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- peers -->
|
<!-- conversations -->
|
||||||
<div class="flex h-full overflow-y-auto">
|
<div class="flex h-full overflow-y-auto">
|
||||||
<div v-if="searchedConversations.length > 0" class="w-full">
|
<div v-if="displayedConversations.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-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 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 }">
|
<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"/>
|
<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"/>
|
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mr-auto">
|
<div class="mr-auto w-full pr-2 min-w-0">
|
||||||
<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="flex justify-between gap-2 min-w-0">
|
||||||
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatTimeAgo(conversation.updated_at) }}</div>
|
<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>
|
||||||
<div v-if="conversation.is_unread" class="my-auto ml-2 mr-2">
|
<div class="flex items-center space-x-1">
|
||||||
<div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
|
<div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
|
||||||
</div>
|
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4"/>
|
||||||
<div v-else-if="conversation.failed_messages_count" class="my-auto ml-2 mr-2">
|
</div>
|
||||||
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></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>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="mx-auto my-auto text-center leading-5">
|
<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 -->
|
<!-- 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">
|
<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">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- is searching, but no results -->
|
<!-- 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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">No Search Results</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- discover -->
|
<!-- 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 -->
|
<!-- search -->
|
||||||
<div v-if="peersCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- peers -->
|
<!-- peers -->
|
||||||
@@ -88,8 +126,8 @@
|
|||||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="min-w-0 flex-1">
|
||||||
<div class="text-gray-900 dark:text-gray-100">{{ peer.custom_display_name ?? peer.display_name }}</div>
|
<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">
|
<div class="flex space-x-1 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
|
||||||
<!-- time ago -->
|
<!-- time ago -->
|
||||||
@@ -150,15 +188,35 @@ import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
|||||||
export default {
|
export default {
|
||||||
name: 'MessagesSidebar',
|
name: 'MessagesSidebar',
|
||||||
components: {MaterialDesignIcon},
|
components: {MaterialDesignIcon},
|
||||||
|
emits: ["conversation-click", "peer-click", "conversation-search-changed", "conversation-filter-changed"],
|
||||||
props: {
|
props: {
|
||||||
peers: Object,
|
peers: Object,
|
||||||
conversations: Array,
|
conversations: Array,
|
||||||
selectedDestinationHash: String,
|
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() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tab: "conversations",
|
tab: "conversations",
|
||||||
conversationsSearchTerm: "",
|
|
||||||
peersSearchTerm: "",
|
peersSearchTerm: "",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -172,16 +230,23 @@ export default {
|
|||||||
formatTimeAgo: function(datetimeString) {
|
formatTimeAgo: function(datetimeString) {
|
||||||
return Utils.formatTimeAgo(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: {
|
computed: {
|
||||||
searchedConversations() {
|
displayedConversations() {
|
||||||
return this.conversations.filter((conversation) => {
|
return this.conversations;
|
||||||
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;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
peersCount() {
|
peersCount() {
|
||||||
return Object.keys(this.peers).length;
|
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 -->
|
<!-- network -->
|
||||||
<div id="network" class="w-full h-full"></div>
|
<div id="network" class="w-full h-full"></div>
|
||||||
<!-- controls -->
|
<!-- controls -->
|
||||||
<div class="absolute flex bottom-0 left-0 bg-gray-100 dark:bg-zinc-900 p-2">
|
<div class="absolute bottom-4 left-4 z-10">
|
||||||
<div class="bg-white dark:bg-zinc-800 rounded shadow min-w-52">
|
<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 text-gray-700 dark:text-gray-300 p-2 cursor-pointer">
|
<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="my-auto">Reticulum Network</div>
|
<div class="flex-1 font-semibold text-gray-900 dark:text-zinc-100">Reticulum Network</div>
|
||||||
<div class="flex ml-auto">
|
<button
|
||||||
<button
|
@click.stop="update"
|
||||||
@click.stop="update"
|
type="button"
|
||||||
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"
|
||||||
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"
|
:disabled="isUpdating"
|
||||||
>
|
>
|
||||||
<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">
|
<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" />
|
<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>
|
||||||
</button>
|
<svg v-else class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
</div>
|
<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>
|
||||||
<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 v-if="isShowingControls" class="px-4 py-3 space-y-3">
|
||||||
<div class="px-1 py-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-start">
|
<input
|
||||||
<div class="flex items-center h-5">
|
v-model="autoReload"
|
||||||
<input
|
type="checkbox"
|
||||||
v-model="autoReload"
|
id="auto-reload"
|
||||||
type="checkbox"
|
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"
|
||||||
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"
|
>
|
||||||
>
|
<label for="auto-reload" class="text-sm font-medium text-gray-900 dark:text-zinc-100 cursor-pointer">Auto Update (5 sec)</label>
|
||||||
</div>
|
|
||||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Auto Update (5 sec)</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="p-1">
|
<div class="pt-2 border-t border-gray-200 dark:border-zinc-800">
|
||||||
<div class="text-black dark:text-white">Interfaces</div>
|
<div class="text-sm font-semibold text-gray-900 dark:text-zinc-100 mb-1">Interfaces</div>
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-300">{{ onlineInterfaces.length }} Online, {{ offlineInterfaces.length }} Offline</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +48,24 @@
|
|||||||
<style>
|
<style>
|
||||||
.vis-tooltip {
|
.vis-tooltip {
|
||||||
color: white !important;
|
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>
|
</style>
|
||||||
|
|
||||||
@@ -53,6 +73,7 @@
|
|||||||
import "vis-network/styles/vis-network.css";
|
import "vis-network/styles/vis-network.css";
|
||||||
import { Network } from "vis-network";
|
import { Network } from "vis-network";
|
||||||
import { DataSet } from "vis-data";
|
import { DataSet } from "vis-data";
|
||||||
|
import * as mdi from "@mdi/js";
|
||||||
import Utils from "../../js/Utils";
|
import Utils from "../../js/Utils";
|
||||||
export default {
|
export default {
|
||||||
name: 'NetworkVisualiser',
|
name: 'NetworkVisualiser',
|
||||||
@@ -62,12 +83,15 @@ export default {
|
|||||||
autoReload: false,
|
autoReload: false,
|
||||||
reloadInterval: null,
|
reloadInterval: null,
|
||||||
isShowingControls: true,
|
isShowingControls: true,
|
||||||
|
isUpdating: false,
|
||||||
interfaces: [],
|
interfaces: [],
|
||||||
pathTable: [],
|
pathTable: [],
|
||||||
announces: {},
|
announces: {},
|
||||||
|
conversations: {},
|
||||||
network: null,
|
network: null,
|
||||||
nodes: new DataSet(),
|
nodes: new DataSet(),
|
||||||
edges: new DataSet(),
|
edges: new DataSet(),
|
||||||
|
iconCache: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
@@ -118,6 +142,70 @@ export default {
|
|||||||
console.log(e);
|
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() {
|
async init() {
|
||||||
|
|
||||||
// create network ui
|
// create network ui
|
||||||
@@ -135,11 +223,23 @@ export default {
|
|||||||
},
|
},
|
||||||
nodes: {
|
nodes: {
|
||||||
color: {
|
color: {
|
||||||
border: "#000000",
|
border: "#e5e7eb",
|
||||||
highlight: {
|
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: {
|
physics: {
|
||||||
barnesHut: {
|
barnesHut: {
|
||||||
@@ -256,14 +356,24 @@ export default {
|
|||||||
},
|
},
|
||||||
async update() {
|
async update() {
|
||||||
|
|
||||||
await this.getConfig();
|
this.isUpdating = true;
|
||||||
await this.getInterfaceStats();
|
try {
|
||||||
await this.getPathTable();
|
await this.getConfig();
|
||||||
await this.getAnnounces();
|
await this.getInterfaceStats();
|
||||||
|
await this.getPathTable();
|
||||||
|
await this.getAnnounces();
|
||||||
|
await this.getConversations();
|
||||||
|
} finally {
|
||||||
|
this.isUpdating = false;
|
||||||
|
}
|
||||||
|
|
||||||
const nodes = [];
|
const nodes = [];
|
||||||
const edges = [];
|
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
|
// add me
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: "me",
|
id: "me",
|
||||||
@@ -275,8 +385,8 @@ export default {
|
|||||||
`Identity: ${this.config?.identity_hash ?? 'Unknown'}`,
|
`Identity: ${this.config?.identity_hash ?? 'Unknown'}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
font: {
|
font: {
|
||||||
color: "#000000",
|
color: fontColor,
|
||||||
background: "#ffffff",
|
background: fontBackground,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,8 +415,8 @@ export default {
|
|||||||
].join("\n"),
|
].join("\n"),
|
||||||
size: 30,
|
size: 30,
|
||||||
font: {
|
font: {
|
||||||
color: "#000000",
|
color: fontColor,
|
||||||
background: '#ffffff',
|
background: fontBackground,
|
||||||
},
|
},
|
||||||
shape: "circularImage",
|
shape: "circularImage",
|
||||||
image: entry.status ? "/assets/images/network-visualiser/interface_connected.png" : "/assets/images/network-visualiser/interface_disconnected.png",
|
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}`,
|
id: `${entry.parent_interface_name}~${entry.name}`,
|
||||||
from: entry.parent_interface_name,
|
from: entry.parent_interface_name,
|
||||||
to: entry.name,
|
to: entry.name,
|
||||||
color: "transparent",
|
color: entry.status ? "#22c55e" : "#ef4444",
|
||||||
|
width: 3,
|
||||||
length: 300,
|
length: 300,
|
||||||
background: {
|
|
||||||
enabled: true,
|
|
||||||
color: entry.status ? "#22c55e" : "#ef4444",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// add edge from me to interface
|
// add edge from me to interface
|
||||||
@@ -335,12 +442,9 @@ export default {
|
|||||||
id: `me~${entry.name}`,
|
id: `me~${entry.name}`,
|
||||||
from: "me",
|
from: "me",
|
||||||
to: entry.name,
|
to: entry.name,
|
||||||
color: "transparent",
|
color: entry.status ? "#22c55e" : "#ef4444",
|
||||||
|
width: 3,
|
||||||
length: 300,
|
length: 300,
|
||||||
background: {
|
|
||||||
enabled: true,
|
|
||||||
color: entry.status ? "#22c55e" : "#ef4444",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,9 +479,22 @@ export default {
|
|||||||
if(announce.aspect === "lxmf.delivery"){
|
if(announce.aspect === "lxmf.delivery"){
|
||||||
|
|
||||||
const name = announce.custom_display_name ?? announce.display_name;
|
const name = announce.custom_display_name ?? announce.display_name;
|
||||||
|
const conversation = this.conversations[announce.destination_hash];
|
||||||
|
|
||||||
node.shape = "circularImage";
|
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.label = name;
|
||||||
node.title = [
|
node.title = [
|
||||||
@@ -423,7 +540,8 @@ export default {
|
|||||||
id: `${entry.interface}~${entry.hash}`,
|
id: `${entry.interface}~${entry.hash}`,
|
||||||
from: entry.interface,
|
from: entry.interface,
|
||||||
to: entry.hash,
|
to: entry.hash,
|
||||||
color: "gray",
|
color: isDarkMode ? "#71717a" : "#9ca3af",
|
||||||
|
width: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -504,3 +622,4 @@ export default {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<!-- nomadnetwork sidebar -->
|
<!-- nomadnetwork sidebar -->
|
||||||
<NomadNetworkSidebar
|
<NomadNetworkSidebar
|
||||||
|
v-if="!isPopoutMode"
|
||||||
:nodes="nodes"
|
:nodes="nodes"
|
||||||
:favourites="favourites"
|
:favourites="favourites"
|
||||||
:selected-destination-hash="selectedNode?.destination_hash"
|
: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">
|
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||||
<!-- node -->
|
<!-- node -->
|
||||||
<div v-if="selectedNode" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
|
<div v-if="selectedNode" class="flex flex-col h-full 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 -->
|
<!-- header -->
|
||||||
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
|
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
|
||||||
|
|
||||||
@@ -38,9 +39,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- node info -->
|
<!-- node info -->
|
||||||
<div class="my-auto dark:text-gray-100">
|
<div class="my-auto dark:text-gray-100 flex-1 min-w-0 flex items-baseline gap-1">
|
||||||
<span class="font-semibold">{{ selectedNode.display_name }}</span>
|
<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"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</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>
|
</div>
|
||||||
|
|
||||||
<!-- identify button -->
|
<!-- identify button -->
|
||||||
@@ -56,6 +57,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- close button -->
|
||||||
<div class="my-auto mr-2">
|
<div class="my-auto mr-2">
|
||||||
<div @click="onCloseNodeViewer" class="cursor-pointer">
|
<div @click="onCloseNodeViewer" class="cursor-pointer">
|
||||||
@@ -103,7 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- page content -->
|
<!-- 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="flex" v-if="isLoadingNodePage">
|
||||||
<div class="my-auto">
|
<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">
|
<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>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-auto">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>
|
</div>
|
||||||
<pre v-else v-html="renderedNodePageContent()" class="h-full break-words whitespace-pre-wrap"></pre>
|
<pre v-else v-html="renderedNodePageContent()" class="h-full break-words whitespace-pre-wrap"></pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +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>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-auto">Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,6 +205,7 @@ import DialogUtils from "../../js/DialogUtils";
|
|||||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||||
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
|
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
|
||||||
import GlobalEmitter from "../../js/GlobalEmitter";
|
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||||
|
import Utils from "../../js/Utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'NomadNetworkPage',
|
name: 'NomadNetworkPage',
|
||||||
@@ -209,10 +236,16 @@ export default {
|
|||||||
nodePageProgress: 0,
|
nodePageProgress: 0,
|
||||||
nodePagePathHistory: [],
|
nodePagePathHistory: [],
|
||||||
nodePageCache: {},
|
nodePageCache: {},
|
||||||
|
currentPageDownloadId: null,
|
||||||
|
|
||||||
isDownloadingNodeFile: false,
|
isDownloadingNodeFile: false,
|
||||||
nodeFilePath: null,
|
nodeFilePath: null,
|
||||||
nodeFileProgress: 0,
|
nodeFileProgress: 0,
|
||||||
|
nodeFileDownloadStartTime: null,
|
||||||
|
nodeFileLastProgressTime: null,
|
||||||
|
nodeFileLastProgressValue: 0,
|
||||||
|
nodeFileDownloadSpeed: null,
|
||||||
|
currentFileDownloadId: null,
|
||||||
|
|
||||||
nomadnetPageDownloadCallbacks: {},
|
nomadnetPageDownloadCallbacks: {},
|
||||||
nomadnetFileDownloadCallbacks: {},
|
nomadnetFileDownloadCallbacks: {},
|
||||||
@@ -256,7 +289,27 @@ export default {
|
|||||||
}, 5000);
|
}, 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: {
|
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) {
|
onElementClick(event) {
|
||||||
|
|
||||||
// find the closest ancestor (or the clicked element itself) with data-action="openNode"
|
// find the closest ancestor (or the clicked element itself) with data-action="openNode"
|
||||||
@@ -287,6 +340,13 @@ export default {
|
|||||||
|
|
||||||
// get data from server
|
// get data from server
|
||||||
const nomadnetPageDownload = json.nomadnet_page_download;
|
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
|
// find download callbacks
|
||||||
const getNomadnetPageDownloadCallbackKey = this.getNomadnetPageDownloadCallbackKey(nomadnetPageDownload.destination_hash, nomadnetPageDownload.page_path);
|
const getNomadnetPageDownloadCallbackKey = this.getNomadnetPageDownloadCallbackKey(nomadnetPageDownload.destination_hash, nomadnetPageDownload.page_path);
|
||||||
@@ -300,6 +360,7 @@ export default {
|
|||||||
if(nomadnetPageDownload.status === "success" && nomadnetPageDownloadCallback.onSuccessCallback){
|
if(nomadnetPageDownload.status === "success" && nomadnetPageDownloadCallback.onSuccessCallback){
|
||||||
nomadnetPageDownloadCallback.onSuccessCallback(nomadnetPageDownload.page_content);
|
nomadnetPageDownloadCallback.onSuccessCallback(nomadnetPageDownload.page_content);
|
||||||
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
||||||
|
this.currentPageDownloadId = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +368,7 @@ export default {
|
|||||||
if(nomadnetPageDownload.status === "failure" && nomadnetPageDownloadCallback.onFailureCallback){
|
if(nomadnetPageDownload.status === "failure" && nomadnetPageDownloadCallback.onFailureCallback){
|
||||||
nomadnetPageDownloadCallback.onFailureCallback(nomadnetPageDownload.failure_reason);
|
nomadnetPageDownloadCallback.onFailureCallback(nomadnetPageDownload.failure_reason);
|
||||||
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
||||||
|
this.currentPageDownloadId = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +385,13 @@ export default {
|
|||||||
|
|
||||||
// get data from server
|
// get data from server
|
||||||
const nomadnetFileDownload = json.nomadnet_file_download;
|
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
|
// find download callbacks
|
||||||
const getNomadnetFileDownloadCallbackKey = this.getNomadnetFileDownloadCallbackKey(nomadnetFileDownload.destination_hash, nomadnetFileDownload.file_path);
|
const getNomadnetFileDownloadCallbackKey = this.getNomadnetFileDownloadCallbackKey(nomadnetFileDownload.destination_hash, nomadnetFileDownload.file_path);
|
||||||
@@ -336,6 +405,7 @@ export default {
|
|||||||
if(nomadnetFileDownload.status === "success" && nomadnetFileDownloadCallback.onSuccessCallback){
|
if(nomadnetFileDownload.status === "success" && nomadnetFileDownloadCallback.onSuccessCallback){
|
||||||
nomadnetFileDownloadCallback.onSuccessCallback(nomadnetFileDownload.file_name, nomadnetFileDownload.file_bytes);
|
nomadnetFileDownloadCallback.onSuccessCallback(nomadnetFileDownload.file_name, nomadnetFileDownload.file_bytes);
|
||||||
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
||||||
|
this.currentFileDownloadId = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +413,7 @@ export default {
|
|||||||
if(nomadnetFileDownload.status === "failure" && nomadnetFileDownloadCallback.onFailureCallback){
|
if(nomadnetFileDownload.status === "failure" && nomadnetFileDownloadCallback.onFailureCallback){
|
||||||
nomadnetFileDownloadCallback.onFailureCallback(nomadnetFileDownload.failure_reason);
|
nomadnetFileDownloadCallback.onFailureCallback(nomadnetFileDownload.failure_reason);
|
||||||
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
||||||
|
this.currentFileDownloadId = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +426,26 @@ export default {
|
|||||||
break;
|
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) {
|
onDestinationPathClick: function(path) {
|
||||||
@@ -470,12 +561,17 @@ export default {
|
|||||||
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
|
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
|
||||||
|
|
||||||
// update current route
|
// update current route
|
||||||
this.$router.replace({
|
const routeName = this.isPopoutMode ? "nomadnetwork-popout" : "nomadnetwork";
|
||||||
name: "nomadnetwork",
|
const routeOptions = {
|
||||||
|
name: routeName,
|
||||||
params: {
|
params: {
|
||||||
destinationHash: destinationHash,
|
destinationHash: destinationHash,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
if(!this.isPopoutMode && this.$route?.query){
|
||||||
|
routeOptions.query = { ...this.$route.query };
|
||||||
|
}
|
||||||
|
this.$router.replace(routeOptions);
|
||||||
|
|
||||||
// get new sequence for this page load
|
// get new sequence for this page load
|
||||||
const seq = ++this.nodePageRequestSequence;
|
const seq = ++this.nodePageRequestSequence;
|
||||||
@@ -726,8 +822,9 @@ export default {
|
|||||||
if(url.startsWith("lxmf@")){
|
if(url.startsWith("lxmf@")){
|
||||||
const destinationHash = url.replace("lxmf@", "");
|
const destinationHash = url.replace("lxmf@", "");
|
||||||
if(destinationHash.length === 32){
|
if(destinationHash.length === 32){
|
||||||
|
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
|
||||||
await this.$router.push({
|
await this.$router.push({
|
||||||
name: "messages",
|
name: routeName,
|
||||||
params: {
|
params: {
|
||||||
destinationHash: destinationHash,
|
destinationHash: destinationHash,
|
||||||
},
|
},
|
||||||
@@ -756,26 +853,72 @@ export default {
|
|||||||
this.isDownloadingNodeFile = true;
|
this.isDownloadingNodeFile = true;
|
||||||
this.nodeFilePath = parsedUrl.path.split("/").pop();
|
this.nodeFilePath = parsedUrl.path.split("/").pop();
|
||||||
this.nodeFileProgress = 0;
|
this.nodeFileProgress = 0;
|
||||||
|
this.nodeFileDownloadStartTime = Date.now();
|
||||||
|
this.nodeFileLastProgressTime = Date.now();
|
||||||
|
this.nodeFileLastProgressValue = 0;
|
||||||
|
this.nodeFileDownloadSpeed = null;
|
||||||
|
|
||||||
// start file download
|
// start file download
|
||||||
this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => {
|
this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => {
|
||||||
|
|
||||||
|
// Calculate final download speed based on actual file size
|
||||||
|
if (this.nodeFileDownloadStartTime) {
|
||||||
|
const totalTime = (Date.now() - this.nodeFileDownloadStartTime) / 1000; // seconds
|
||||||
|
const fileSizeBytes = atob(fileBytesBase64).length;
|
||||||
|
if (totalTime > 0) {
|
||||||
|
this.nodeFileDownloadSpeed = fileSizeBytes / totalTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// no longer downloading
|
// no longer downloading
|
||||||
this.isDownloadingNodeFile = false;
|
this.isDownloadingNodeFile = false;
|
||||||
|
|
||||||
// download file to browser
|
// download file to browser
|
||||||
this.downloadFileFromBase64(fileName, fileBytesBase64);
|
this.downloadFileFromBase64(fileName, fileBytesBase64);
|
||||||
|
|
||||||
|
// Clear speed after a moment
|
||||||
|
setTimeout(() => {
|
||||||
|
this.nodeFileDownloadSpeed = null;
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
}, (failureReason) => {
|
}, (failureReason) => {
|
||||||
|
|
||||||
// no longer downloading
|
// no longer downloading
|
||||||
this.isDownloadingNodeFile = false;
|
this.isDownloadingNodeFile = false;
|
||||||
|
this.nodeFileDownloadSpeed = null;
|
||||||
|
|
||||||
// show error message
|
// show error message
|
||||||
DialogUtils.alert(`Failed to download file: ${failureReason}`);
|
DialogUtils.alert(`Failed to download file: ${failureReason}`);
|
||||||
|
|
||||||
}, (progress) => {
|
}, (progress) => {
|
||||||
this.nodeFileProgress = Math.round(progress * 100);
|
const currentTime = Date.now();
|
||||||
|
const progressValue = progress;
|
||||||
|
this.nodeFileProgress = Math.round(progressValue * 100);
|
||||||
|
|
||||||
|
// Calculate estimated download speed based on progress rate
|
||||||
|
if (this.nodeFileDownloadStartTime && progressValue > 0) {
|
||||||
|
const elapsedTime = (currentTime - this.nodeFileDownloadStartTime) / 1000; // seconds
|
||||||
|
if (elapsedTime > 0.5) { // Only calculate after at least 0.5 seconds
|
||||||
|
// Estimate total file size based on progress rate
|
||||||
|
// If we've downloaded progressValue in elapsedTime, estimate total time
|
||||||
|
const estimatedTotalTime = elapsedTime / progressValue;
|
||||||
|
// Estimate file size based on average download speed assumption
|
||||||
|
// We'll refine this when download completes with actual size
|
||||||
|
// For now, estimate based on typical mesh network file sizes (100KB-10MB range)
|
||||||
|
// Use a conservative estimate that will be updated when download completes
|
||||||
|
const estimatedFileSize = 500 * 1024; // Start with 500KB estimate
|
||||||
|
const estimatedBytesDownloaded = estimatedFileSize * progressValue;
|
||||||
|
const estimatedSpeed = estimatedBytesDownloaded / elapsedTime;
|
||||||
|
|
||||||
|
// Only update if we have a reasonable estimate
|
||||||
|
if (estimatedSpeed > 0 && estimatedSpeed < 100 * 1024 * 1024) { // Cap at 100MB/s
|
||||||
|
this.nodeFileDownloadSpeed = estimatedSpeed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nodeFileLastProgressTime = currentTime;
|
||||||
|
this.nodeFileLastProgressValue = progressValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -829,6 +972,9 @@ export default {
|
|||||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
|
||||||
|
|
||||||
},
|
},
|
||||||
|
formatBytesPerSecond: function(bytesPerSecond) {
|
||||||
|
return Utils.formatBytesPerSecond(bytesPerSecond);
|
||||||
|
},
|
||||||
onNodeClick: function(node) {
|
onNodeClick: function(node) {
|
||||||
|
|
||||||
// update selected node
|
// update selected node
|
||||||
@@ -877,10 +1023,18 @@ export default {
|
|||||||
// clear selected node
|
// clear selected node
|
||||||
this.selectedNode = null;
|
this.selectedNode = null;
|
||||||
|
|
||||||
|
if(this.isPopoutMode){
|
||||||
|
window.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// update current route
|
// update current route
|
||||||
this.$router.replace({
|
const routeName = this.isPopoutMode ? "nomadnetwork-popout" : "nomadnetwork";
|
||||||
name: "nomadnetwork",
|
const routeOptions = { name: routeName };
|
||||||
});
|
if(!this.isPopoutMode && this.$route?.query){
|
||||||
|
routeOptions.query = { ...this.$route.query };
|
||||||
|
}
|
||||||
|
this.$router.replace(routeOptions);
|
||||||
|
|
||||||
},
|
},
|
||||||
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
|
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
|
||||||
@@ -925,6 +1079,11 @@ export default {
|
|||||||
DialogUtils.alert(e.response?.data?.message ?? "Failed to identify!");
|
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) {
|
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -975,6 +1134,22 @@ export default {
|
|||||||
renderedNodePageContent() {
|
renderedNodePageContent() {
|
||||||
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
|
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>
|
</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>
|
<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 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 flex-col h-full space-y-2 p-2 overflow-y-auto">
|
<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="glass-card space-y-5">
|
||||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
<div class="space-y-2">
|
||||||
<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="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Diagnostics</div>
|
||||||
<div class="dark:divide-zinc-700 text-gray-900 dark:text-gray-100 p-2">
|
<div class="text-2xl font-semibold text-gray-900 dark:text-white">Ping Mesh Peers</div>
|
||||||
Only lxmf.delivery destinations can be pinged.
|
<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>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- inputs -->
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
<div>
|
||||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
<label class="glass-label">Destination Hash</label>
|
||||||
|
<input v-model="destinationHash" type="text" placeholder="e.g. 7b746057a7294469799cd8d7d429676a" class="input-field font-mono"/>
|
||||||
<div class="p-2">
|
</div>
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Destination Hash</div>
|
<div>
|
||||||
<div class="flex">
|
<label class="glass-label">Ping Timeout (seconds)</label>
|
||||||
<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">
|
<input v-model="timeout" type="number" min="1" class="input-field"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Ping Timeout (seconds)</div>
|
<button v-if="!isRunning" @click="start" type="button" class="primary-chip px-4 py-2 text-sm">
|
||||||
<div class="flex">
|
<MaterialDesignIcon icon-name="play" class="w-4 h-4"/>
|
||||||
<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">
|
Start Ping
|
||||||
</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
|
|
||||||
</button>
|
</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
|
Stop
|
||||||
</button>
|
</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
|
Clear Results
|
||||||
</button>
|
</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
|
Drop Path
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- results -->
|
<div class="glass-card flex flex-col min-h-[320px] space-y-3">
|
||||||
<div class="flex flex-col h-full bg-white dark:bg-zinc-800 rounded shadow overflow-hidden min-h-52">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<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>
|
||||||
<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 class="text-sm font-semibold text-gray-900 dark:text-white">Console Output</div>
|
||||||
<div v-for="pingResult of pingResults" class="w-fit">{{ pingResult }}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,9 +87,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import {CanceledError} from "axios";
|
import {CanceledError} from "axios";
|
||||||
import DialogUtils from "../../js/DialogUtils";
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'PingPage',
|
name: 'PingPage',
|
||||||
|
components: {
|
||||||
|
MaterialDesignIcon,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isRunning: false,
|
isRunning: false,
|
||||||
@@ -72,6 +102,7 @@ export default {
|
|||||||
seq: 0,
|
seq: 0,
|
||||||
pingResults: [],
|
pingResults: [],
|
||||||
abortController: null,
|
abortController: null,
|
||||||
|
lastPingSummary: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
@@ -116,10 +147,13 @@ export default {
|
|||||||
},
|
},
|
||||||
async stop() {
|
async stop() {
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
this.abortController.abort();
|
if(this.abortController){
|
||||||
|
this.abortController.abort();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async clear() {
|
async clear() {
|
||||||
this.pingResults = [];
|
this.pingResults = [];
|
||||||
|
this.lastPingSummary = null;
|
||||||
},
|
},
|
||||||
async sleep(millis) {
|
async sleep(millis) {
|
||||||
return new Promise((resolve, reject) => setTimeout(resolve, millis));
|
return new Promise((resolve, reject) => setTimeout(resolve, millis));
|
||||||
@@ -168,6 +202,15 @@ export default {
|
|||||||
|
|
||||||
// update ui
|
// update ui
|
||||||
this.addPingResult(info.join(" "));
|
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) {
|
} catch(e) {
|
||||||
|
|
||||||
@@ -181,6 +224,9 @@ export default {
|
|||||||
// add ping error to results
|
// add ping error to results
|
||||||
const message = e.response?.data?.message ?? e;
|
const message = e.response?.data?.message ?? e;
|
||||||
this.addPingResult(`seq=${this.seq} error=${message}`);
|
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"/>
|
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
|
||||||
<title>Reticulum MeshChat</title>
|
<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>
|
</head>
|
||||||
<body class="bg-gray-100">
|
<body class="bg-gray-100">
|
||||||
<div id="app"></div>
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -25,6 +25,13 @@ class Utils {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static formatNumber(num) {
|
||||||
|
if(num === 0){
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
static parseSeconds(secondsToFormat) {
|
static parseSeconds(secondsToFormat) {
|
||||||
secondsToFormat = Number(secondsToFormat);
|
secondsToFormat = Number(secondsToFormat);
|
||||||
var days = Math.floor(secondsToFormat / (3600 * 24));
|
var days = Math.floor(secondsToFormat / (3600 * 24));
|
||||||
@@ -127,6 +134,22 @@ class Utils {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static formatBytesPerSecond(bytesPerSecond) {
|
||||||
|
|
||||||
|
if(bytesPerSecond === 0 || bytesPerSecond == null){
|
||||||
|
return '0 B/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const decimals = 1;
|
||||||
|
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
static formatFrequency(hz) {
|
static formatFrequency(hz) {
|
||||||
|
|
||||||
if(hz === 0 || hz == null){
|
if(hz === 0 || hz == null){
|
||||||
@@ -4,6 +4,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
|
|||||||
import vClickOutside from "click-outside-vue3";
|
import vClickOutside from "click-outside-vue3";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
import "./fonts/RobotoMonoNerdFont/font.css";
|
import "./fonts/RobotoMonoNerdFont/font.css";
|
||||||
|
import { ensureCodec2ScriptsLoaded } from "./js/Codec2Loader";
|
||||||
|
|
||||||
import App from './components/App.vue';
|
import App from './components/App.vue';
|
||||||
|
|
||||||
@@ -50,6 +51,13 @@ const router = createRouter({
|
|||||||
props: true,
|
props: true,
|
||||||
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
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",
|
name: "network-visualiser",
|
||||||
path: '/network-visualiser',
|
path: '/network-visualiser',
|
||||||
@@ -61,6 +69,13 @@ const router = createRouter({
|
|||||||
props: true,
|
props: true,
|
||||||
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
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",
|
name: "propagation-nodes",
|
||||||
path: '/propagation-nodes',
|
path: '/propagation-nodes',
|
||||||
@@ -86,11 +101,21 @@ const router = createRouter({
|
|||||||
path: '/tools',
|
path: '/tools',
|
||||||
component: defineAsyncComponent(() => import("./components/tools/ToolsPage.vue")),
|
component: defineAsyncComponent(() => import("./components/tools/ToolsPage.vue")),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "call",
|
||||||
|
path: '/call',
|
||||||
|
component: defineAsyncComponent(() => import("./components/call/CallPage.vue")),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
createApp(App)
|
async function bootstrap() {
|
||||||
.use(router)
|
await ensureCodec2ScriptsLoaded();
|
||||||
.use(vuetify)
|
createApp(App)
|
||||||
.use(vClickOutside)
|
.use(router)
|
||||||
.mount('#app');
|
.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 |