Compare commits
241 Commits
interface-
...
deepsource
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
| 69d8bab9e4 | |||
| adac0e5bb1 | |||
| 12313d34ee | |||
| 55126eaf82 | |||
| aa774f3511 | |||
| e0e2bbf091 | |||
| 61ada872c0 | |||
|
|
3260bffd60 | ||
|
|
bbc1eec48e | ||
|
|
72266680a2 | ||
|
|
f0336873db | ||
|
|
d9a39f1ea9 | ||
| f0edb4bc8d | |||
| e9d45f257e | |||
| 00e0461a16 | |||
| c56b982df5 | |||
|
|
002360399c | ||
|
|
c9f4ef64c1 | ||
|
|
ffe2cb884d | ||
|
|
d6847d262a | ||
|
|
65df111b87 | ||
|
|
747236ae8b | ||
|
|
4e55006084 | ||
|
|
dcaffe2594 | ||
|
|
094f6cb5ec | ||
|
|
0c0f059ec4 | ||
|
|
9031c1a3d7 | ||
|
|
64adad27f8 | ||
|
|
4734e62468 | ||
|
|
37cc6aa158 | ||
|
|
f3bf0abd84 | ||
|
|
90445467e1 | ||
|
|
51bdd35f01 | ||
|
|
817d5b5e59 | ||
|
|
a094a741a8 | ||
|
|
24acbaf223 | ||
|
|
0bb171a81b | ||
|
|
b5a54dd120 | ||
|
|
86cfddce52 | ||
|
|
97071c7edb | ||
|
|
a58f73357a | ||
|
|
6b3639dcd2 | ||
|
|
47a84fc110 | ||
|
|
588780d632 | ||
|
|
5b783399f8 | ||
|
|
df533fb1bf | ||
|
|
e757a2f022 | ||
|
|
ce56c205c6 | ||
|
|
66b619c398 | ||
|
|
458a387517 | ||
|
|
e97352713d | ||
|
|
07a41215be | ||
|
|
e9a9e9f831 | ||
|
|
b8d388fa56 | ||
|
|
d7080c8ca1 | ||
|
|
7c20529d62 | ||
|
|
c6eeab97e6 | ||
|
|
10c85cdba0 | ||
|
|
9ea98eb0f0 | ||
|
|
2662f96c8b | ||
|
|
59deac6d07 | ||
|
|
9d60707515 | ||
|
|
6f321741d7 | ||
|
|
eaf1b75c54 | ||
|
|
c59ed015ce | ||
|
|
d13b395a2c | ||
|
|
59c185354b | ||
|
|
9e7d0cdfeb | ||
|
|
e6ff5097c0 | ||
|
|
ee08a5619c | ||
|
|
c0bb0763a1 | ||
|
|
b6e41b3027 | ||
|
|
030a1e64a9 | ||
|
|
5802671e0d | ||
|
|
03d7b669ae | ||
|
|
a81c6787c7 | ||
|
|
a500b58d05 | ||
|
|
94179f9779 | ||
|
|
93b6104aef | ||
|
|
10bef61a90 | ||
|
|
0f31c9f8c0 | ||
|
|
2c518d1b31 | ||
|
|
176aed98ff | ||
|
|
e1ae122297 | ||
|
|
f6b1c65faa | ||
|
|
4f497620c8 | ||
|
|
4d816ae87c | ||
|
|
df8e98366b | ||
|
|
54b1d56107 | ||
|
|
ba118f7a9c | ||
|
|
e48c26042c | ||
|
|
d95878c659 | ||
|
|
734eaeed1b | ||
|
|
33e4888737 | ||
|
|
408a62dffe | ||
|
|
43a5a907c0 | ||
|
|
620c147dbd | ||
|
|
4555de5836 | ||
|
|
842dbeb0b4 | ||
|
|
9d2f3eebc8 | ||
|
|
b21e3fc026 | ||
|
|
abd70ae606 | ||
|
|
1e2d4387e7 | ||
|
|
d4b5b99045 | ||
|
|
ce52532522 | ||
|
|
6c43c2cc4f | ||
|
|
c5e4776dc1 | ||
|
|
dabd6c4a37 | ||
|
|
dacd2ea3f2 | ||
|
|
9741cdcd60 | ||
|
|
f87a360d5c | ||
|
|
9b62f60e18 | ||
|
|
019ba93d80 | ||
|
|
01562aff75 | ||
|
|
e2b844f2c2 | ||
|
|
c555d8f15b | ||
|
|
0dc3dc955f | ||
|
|
812ff6b887 | ||
|
|
3a13442bb9 | ||
|
|
d7375081f3 | ||
|
|
68ebe4a1c9 | ||
|
|
8b2520f3fa | ||
|
|
5e068b7341 | ||
|
|
d796722772 | ||
|
|
adad97e917 | ||
|
|
59eba2ff64 | ||
|
|
1bad77553c | ||
|
|
b215c4ac31 | ||
|
|
6af4e53de4 | ||
|
|
558e4c8b3d | ||
|
|
7d1681fbf1 | ||
|
|
580c907138 | ||
|
|
4ae83ca980 | ||
|
|
29c062d701 | ||
|
|
d4b204029a | ||
|
|
6f325d24e7 | ||
|
|
b5f9403c52 | ||
|
|
cf059fab63 | ||
|
|
a3565ef063 | ||
|
|
541dd8d4f1 | ||
|
|
6a1243f482 | ||
|
|
9b36120faa | ||
|
|
ff38d4c239 | ||
|
|
c5955295d7 | ||
|
|
5d022888b7 | ||
|
|
6b4bf0e31a | ||
|
|
48e56e5285 | ||
|
|
4b6978f7cc | ||
|
|
d3e8c2de9a | ||
|
|
282f08edb1 | ||
|
|
629e8d47fb | ||
|
|
3f73beff2e | ||
|
|
c55a02ffdc | ||
|
|
c26d27d01c | ||
|
|
6d233b759e | ||
|
|
1306593efc | ||
|
|
8a85a730ab | ||
|
|
e490782d41 | ||
|
|
7e63c1e752 | ||
|
|
64562c2dc8 | ||
|
|
b0e7e1d425 | ||
|
|
ddf144688e | ||
|
|
ed8ac77ecc | ||
|
|
b19ee171eb | ||
|
|
fabb6d5ca3 | ||
|
|
0b6b390388 | ||
|
|
82c67bb71c | ||
|
|
372e61ed7c | ||
|
|
9815decc99 | ||
|
|
65dfd6c540 | ||
|
|
de049aead5 | ||
|
|
99b225e484 | ||
|
|
3b47d2a521 |
10
.deepsource.toml
Normal file
10
.deepsource.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
version = 1
|
||||||
|
|
||||||
|
[[analyzers]]
|
||||||
|
name = "python"
|
||||||
|
|
||||||
|
[analyzers.meta]
|
||||||
|
runtime_version = "3.x.x"
|
||||||
|
|
||||||
|
[[analyzers]]
|
||||||
|
name = "docker"
|
||||||
55
.dockerignore
Normal file
55
.dockerignore
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
|
donate.md
|
||||||
|
screenshots/
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.github/
|
||||||
|
electron/
|
||||||
|
|
||||||
|
# Build artifacts and cache
|
||||||
|
public/
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
20
.github/workflows/bearer-pr.yml
vendored
Normal file
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
|
||||||
105
.github/workflows/build.yml
vendored
105
.github/workflows/build.yml
vendored
@@ -4,28 +4,54 @@ 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
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_windows:
|
build_windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_windows == 'true')
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Repo
|
- name: Clone Repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||||
|
|
||||||
- name: Install NodeJS
|
- name: Install NodeJS
|
||||||
uses: actions/setup-node@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@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install Python Deps
|
- name: Install Python Deps
|
||||||
run: pip install -r requirements.txt
|
run: |
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\pip install --upgrade pip
|
||||||
|
venv\Scripts\pip install -r requirements.txt
|
||||||
|
|
||||||
- name: Install NodeJS Deps
|
- name: Install NodeJS Deps
|
||||||
run: npm install
|
run: npm install
|
||||||
@@ -35,7 +61,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
allowUpdates: true
|
allowUpdates: true
|
||||||
@@ -46,24 +72,28 @@ jobs:
|
|||||||
|
|
||||||
build_mac:
|
build_mac:
|
||||||
runs-on: macos-13
|
runs-on: macos-13
|
||||||
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_mac == 'true')
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Repo
|
- name: Clone Repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||||
|
|
||||||
- name: Install NodeJS
|
- name: Install NodeJS
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
|
||||||
- name: Install Python Deps
|
- name: Install Python Deps
|
||||||
run: pip install -r requirements.txt
|
run: |
|
||||||
|
python3 -m venv venv
|
||||||
|
venv/bin/pip install --upgrade pip
|
||||||
|
venv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
- name: Install NodeJS Deps
|
- name: Install NodeJS Deps
|
||||||
run: npm install
|
run: npm install
|
||||||
@@ -73,7 +103,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
allowUpdates: true
|
allowUpdates: true
|
||||||
@@ -84,24 +114,31 @@ jobs:
|
|||||||
|
|
||||||
build_linux:
|
build_linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_linux == 'true')
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Repo
|
- name: Clone Repo
|
||||||
uses: actions/checkout@v1
|
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||||
|
|
||||||
- name: Install NodeJS
|
- name: Install NodeJS
|
||||||
uses: actions/setup-node@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@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install patchelf
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y patchelf
|
||||||
|
|
||||||
- name: Install Python Deps
|
- name: Install Python Deps
|
||||||
run: pip install -r requirements.txt
|
run: |
|
||||||
|
python3 -m venv venv
|
||||||
|
venv/bin/pip install --upgrade pip
|
||||||
|
venv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
- name: Install NodeJS Deps
|
- name: Install NodeJS Deps
|
||||||
run: npm install
|
run: npm install
|
||||||
@@ -111,47 +148,51 @@ jobs:
|
|||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
allowUpdates: true
|
allowUpdates: true
|
||||||
replacesArtifacts: true
|
replacesArtifacts: true
|
||||||
omitDraftDuringUpdate: true
|
omitDraftDuringUpdate: true
|
||||||
omitNameDuringUpdate: true
|
omitNameDuringUpdate: true
|
||||||
artifacts: "dist/*-linux.AppImage"
|
artifacts: "dist/*-linux.AppImage,dist/*-linux.deb"
|
||||||
|
|
||||||
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
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Repo
|
- name: Clone Repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||||
|
|
||||||
|
- name: Set lowercase repository owner
|
||||||
|
run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||||
|
|
||||||
- name: Log in to the GitHub Container registry
|
- name: Log in to the GitHub Container registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker images
|
- name: Build and push Docker images
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: >-
|
||||||
ghcr.io/liamcottle/reticulum-meshchat:latest
|
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:latest,
|
||||||
ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }}
|
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchat:${{ github.ref_name }}
|
||||||
labels: |
|
labels: >-
|
||||||
org.opencontainers.image.title=Reticulum MeshChat
|
org.opencontainers.image.title=Reticulum MeshChat,
|
||||||
org.opencontainers.image.description=Docker image for Reticulum MeshChat
|
org.opencontainers.image.description=Docker image for Reticulum MeshChat,
|
||||||
org.opencontainers.image.url=https://github.com/liamcottle/reticulum-meshchat/pkgs/container/reticulum-meshchat/
|
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchat/
|
||||||
|
|||||||
42
.github/workflows/manual-docker-build.yml
vendored
42
.github/workflows/manual-docker-build.yml
vendored
@@ -1,42 +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@v4
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to the GitHub Container registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push Docker images
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/liamcottle/reticulum-meshchat:latest
|
|
||||||
ghcr.io/liamcottle/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/liamcottle/reticulum-meshchat/pkgs/container/reticulum-meshchat/
|
|
||||||
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,3 +9,5 @@ node_modules
|
|||||||
|
|
||||||
# local storage
|
# local storage
|
||||||
storage/
|
storage/
|
||||||
|
|
||||||
|
*.pyc
|
||||||
18
Dockerfile
18
Dockerfile
@@ -1,5 +1,11 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
@@ -13,13 +19,19 @@ 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/public public
|
||||||
|
|||||||
41
Makefile
Normal file
41
Makefile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
.PHONY: install run clean build build-appimage build-exe dist
|
||||||
|
|
||||||
|
VENV = venv
|
||||||
|
PYTHON = $(VENV)/bin/python
|
||||||
|
PIP = $(VENV)/bin/pip
|
||||||
|
NPM = npm
|
||||||
|
|
||||||
|
install: $(VENV) node_modules
|
||||||
|
|
||||||
|
$(VENV):
|
||||||
|
python3 -m venv $(VENV)
|
||||||
|
$(PIP) install --upgrade pip
|
||||||
|
$(PIP) install -r requirements.txt
|
||||||
|
|
||||||
|
node_modules:
|
||||||
|
$(NPM) install
|
||||||
|
|
||||||
|
run: install
|
||||||
|
$(PYTHON) meshchat.py
|
||||||
|
|
||||||
|
build: install
|
||||||
|
$(NPM) run build
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(VENV)
|
||||||
|
rm -rf node_modules
|
||||||
|
rm -rf build
|
||||||
|
rm -rf dist
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
324
README.md
324
README.md
@@ -1,311 +1,45 @@
|
|||||||
<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-3FPBfiEwioWHFix3kZqe5bdU9F5o8mG8dh-%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 recieving messages from users.
|
||||||
|
- [ ] Spam filter (based on keywords)
|
||||||
|
- [ ] Multi-identity support.
|
||||||
|
- [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.
|
||||||
|
- [x] Latest updates for NPM and Python dependencies (bleeding edge)
|
||||||
|
- [x] Ruff linting, CodeQL Advanced and Bearer SAST fixes.
|
||||||
|
|
||||||
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
|
||||||
- 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
|
### Building in Docker
|
||||||
|
|
||||||
For a full list of command line options, you can run;
|
```bash
|
||||||
|
make docker-build
|
||||||
```
|
|
||||||
python meshchat.py --help
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
The build will be in the `dist` directory.
|
||||||
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
|
## Development
|
||||||
|
|
||||||
options:
|
```bash
|
||||||
-h, --help show this help message and exit
|
make develop
|
||||||
--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
|
|
||||||
|
|
||||||
The first time you run this application, a new Reticulum identity is generated and saved to `storage/identity`.
|
|
||||||
|
|
||||||
If you want to use an existing identity;
|
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
```
|
|
||||||
rnid --generate ./new_identity_file
|
|
||||||
```
|
|
||||||
|
|
||||||
If you don't have access to the `rnid` command, you can use the following:
|
|
||||||
|
|
||||||
```
|
|
||||||
python meshchat.py --generate-identity-file ./new_identity_file
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, you can provide a base64 encoded private key, like so;
|
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
- [ ] support for managing Reticulum interfaces via the web ui
|
|
||||||
- [x] AutoInterface
|
|
||||||
- [x] RNodeInterface
|
|
||||||
- [x] TCPClientInterface
|
|
||||||
- [x] TCPServerInterface
|
|
||||||
- [x] UDPInterface
|
|
||||||
- [ ] I2PInterface
|
|
||||||
- [ ] SerialInterface
|
|
||||||
- [ ] PipeInterface
|
|
||||||
- [ ] KISSInterface
|
|
||||||
- [ ] AX25KISSInterface
|
|
||||||
- [ ] Other Options
|
|
||||||
- [ ] network_name
|
|
||||||
- [ ] passphrase
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
31
TODO.md
Normal file
31
TODO.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
1. for messages fix:
|
||||||
|
|
||||||
|
convo goes off edge, near edge should be ... 3 dots
|
||||||
|
|
||||||
|
long names push the last message/announced seconds/time to right and nearly off the side, fix please
|
||||||
|
|
||||||
|
2. interfaces:
|
||||||
|
|
||||||
|
3 dots background circle is a oval, fix to be circle
|
||||||
|
|
||||||
|
on 3 dots clicked there is still white background the buttons have dark backgrounds though but main dropdown window is white fix depdning on theme
|
||||||
|
|
||||||
|
also on 3 dots drop down it still makes me scroll down in that interfaces window, we can expand that interfaces box os something so this crap doesnt hapen or if dropdown is above it
|
||||||
|
|
||||||
|
rework propagation nodes page with new UI/UX please like rest of app.
|
||||||
|
|
||||||
|
1. the attachment dropups/popups are white on dark mode, they need a ui/ux rework.
|
||||||
|
|
||||||
|
2. for settings add ability to set inbound stamp, ref lxmf via python -c if needed.
|
||||||
|
|
||||||
|
3. add multi-identity / account suport and a switcher at bottom with ability to create, delete or import/export identies from other apps.
|
||||||
|
|
||||||
|
for all this you will likely need to look at my ren chat app for stamps, multi-identity, /mnt/projects/ren-messenger/
|
||||||
|
|
||||||
|
its pretty simple.
|
||||||
|
|
||||||
|
translator tool
|
||||||
|
reticulum documentation tool
|
||||||
|
lxmfy bot tool
|
||||||
|
page downloader tool
|
||||||
|
page snapshots
|
||||||
215
database.py
215
database.py
@@ -1,60 +1,73 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from peewee import *
|
from peewee import * # noqa: F403
|
||||||
from playhouse.migrate import migrate as migrate_database, SqliteMigrator
|
from playhouse.migrate import SqliteMigrator
|
||||||
|
from playhouse.migrate import migrate as migrate_database
|
||||||
|
|
||||||
latest_version = 5 # increment each time new database migrations are added
|
latest_version = 6 # increment each time new database migrations are added
|
||||||
database = DatabaseProxy() # use a proxy object, as we will init real db client inside meshchat.py
|
database = (
|
||||||
|
DatabaseProxy() # noqa: F405
|
||||||
|
) # use a proxy object, as we will init real db client inside meshchat.py
|
||||||
migrator = SqliteMigrator(database)
|
migrator = SqliteMigrator(database)
|
||||||
|
|
||||||
|
|
||||||
# migrates the database
|
# migrates the database
|
||||||
def migrate(current_version):
|
def migrate(current_version):
|
||||||
|
|
||||||
# migrate to version 2
|
# migrate to version 2
|
||||||
if current_version < 2:
|
if current_version < 2:
|
||||||
migrate_database(
|
migrate_database(
|
||||||
migrator.add_column("lxmf_messages", 'delivery_attempts', LxmfMessage.delivery_attempts),
|
migrator.add_column(
|
||||||
migrator.add_column("lxmf_messages", 'next_delivery_attempt_at', LxmfMessage.next_delivery_attempt_at),
|
"lxmf_messages", "delivery_attempts", LxmfMessage.delivery_attempts,
|
||||||
|
),
|
||||||
|
migrator.add_column(
|
||||||
|
"lxmf_messages",
|
||||||
|
"next_delivery_attempt_at",
|
||||||
|
LxmfMessage.next_delivery_attempt_at,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# migrate to version 3
|
# migrate to version 3
|
||||||
if current_version < 3:
|
if current_version < 3:
|
||||||
migrate_database(
|
migrate_database(
|
||||||
migrator.add_column("lxmf_messages", 'rssi', LxmfMessage.rssi),
|
migrator.add_column("lxmf_messages", "rssi", LxmfMessage.rssi),
|
||||||
migrator.add_column("lxmf_messages", 'snr', LxmfMessage.snr),
|
migrator.add_column("lxmf_messages", "snr", LxmfMessage.snr),
|
||||||
migrator.add_column("lxmf_messages", 'quality', LxmfMessage.quality),
|
migrator.add_column("lxmf_messages", "quality", LxmfMessage.quality),
|
||||||
)
|
)
|
||||||
|
|
||||||
# migrate to version 4
|
# migrate to version 4
|
||||||
if current_version < 4:
|
if current_version < 4:
|
||||||
migrate_database(
|
migrate_database(
|
||||||
migrator.add_column("lxmf_messages", 'method', LxmfMessage.method),
|
migrator.add_column("lxmf_messages", "method", LxmfMessage.method),
|
||||||
)
|
)
|
||||||
|
|
||||||
# migrate to version 5
|
# migrate to version 5
|
||||||
if current_version < 5:
|
if current_version < 5:
|
||||||
migrate_database(
|
migrate_database(
|
||||||
migrator.add_column("announces", 'rssi', Announce.rssi),
|
migrator.add_column("announces", "rssi", Announce.rssi),
|
||||||
migrator.add_column("announces", 'snr', Announce.snr),
|
migrator.add_column("announces", "snr", Announce.snr),
|
||||||
migrator.add_column("announces", 'quality', Announce.quality),
|
migrator.add_column("announces", "quality", Announce.quality),
|
||||||
|
)
|
||||||
|
|
||||||
|
# migrate to version 6
|
||||||
|
if current_version < 6:
|
||||||
|
migrate_database(
|
||||||
|
migrator.add_column("lxmf_messages", "is_spam", LxmfMessage.is_spam),
|
||||||
)
|
)
|
||||||
|
|
||||||
return latest_version
|
return latest_version
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(Model):
|
class BaseModel(Model): # noqa: F405
|
||||||
class Meta:
|
class Meta:
|
||||||
database = database
|
database = database
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
|
id = BigAutoField() # noqa: F405
|
||||||
id = BigAutoField()
|
key = CharField(unique=True) # noqa: F405
|
||||||
key = CharField(unique=True)
|
value = TextField() # noqa: F405
|
||||||
value = TextField()
|
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
|
||||||
|
|
||||||
# define table name
|
# define table name
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -62,19 +75,26 @@ class Config(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Announce(BaseModel):
|
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 = ( # noqa: F405
|
||||||
|
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
|
||||||
|
|
||||||
id = BigAutoField()
|
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
destination_hash = CharField(unique=True) # unique destination hash that was announced
|
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
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
|
# define table name
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -82,40 +102,63 @@ class Announce(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CustomDestinationDisplayName(BaseModel):
|
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
|
||||||
|
|
||||||
id = BigAutoField()
|
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
destination_hash = CharField(unique=True) # unique destination hash
|
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
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
|
# define table name
|
||||||
class Meta:
|
class Meta:
|
||||||
table_name = "custom_destination_display_names"
|
table_name = "custom_destination_display_names"
|
||||||
|
|
||||||
|
|
||||||
class LxmfMessage(BaseModel):
|
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
|
||||||
|
|
||||||
id = BigAutoField()
|
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
hash = CharField(unique=True) # unique lxmf message hash
|
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
source_hash = CharField(index=True)
|
|
||||||
destination_hash = CharField(index=True)
|
# define table name
|
||||||
state = CharField() # state is converted from internal int to a human friendly string
|
class Meta:
|
||||||
progress = FloatField() # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
|
table_name = "favourite_destinations"
|
||||||
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
|
class LxmfMessage(BaseModel):
|
||||||
next_delivery_attempt_at = FloatField(null=True) # timestamp of when the message will attempt delivery again
|
id = BigAutoField() # noqa: F405
|
||||||
title = TextField()
|
hash = CharField(unique=True) # noqa: F405 # unique lxmf message hash
|
||||||
content = TextField()
|
source_hash = CharField(index=True) # noqa: F405
|
||||||
fields = TextField() # json string
|
destination_hash = CharField(index=True) # noqa: F405
|
||||||
timestamp = FloatField() # timestamp of when the message was originally created (before ever being sent)
|
state = ( # noqa: F405
|
||||||
rssi = IntegerField(null=True)
|
CharField() # noqa: F405
|
||||||
snr = FloatField(null=True)
|
) # state is converted from internal int to a human friendly string
|
||||||
quality = FloatField(null=True)
|
progress = FloatField() # noqa: F405 # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
|
||||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
is_incoming = BooleanField() # noqa: F405 # if true, we should ignore state, it's set to draft by default on incoming messages
|
||||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
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 = ( # noqa: F405
|
||||||
|
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(timezone.utc)) # noqa: F405
|
||||||
|
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
|
|
||||||
# define table name
|
# define table name
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -123,13 +166,12 @@ class LxmfMessage(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LxmfConversationReadState(BaseModel):
|
class LxmfConversationReadState(BaseModel):
|
||||||
|
id = BigAutoField() # noqa: F405
|
||||||
|
destination_hash = CharField(unique=True) # noqa: F405 # unique destination hash
|
||||||
|
last_read_at = DateTimeField() # noqa: F405
|
||||||
|
|
||||||
id = BigAutoField()
|
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
destination_hash = CharField(unique=True) # unique destination hash
|
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
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
|
# define table name
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -137,16 +179,43 @@ class LxmfConversationReadState(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LxmfUserIcon(BaseModel):
|
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 = ( # noqa: F405
|
||||||
|
CharField() # noqa: F405
|
||||||
|
) # hex colour to use for background (background colour)
|
||||||
|
|
||||||
id = BigAutoField()
|
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
destination_hash = CharField(unique=True) # unique destination hash
|
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
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
|
# define table name
|
||||||
class Meta:
|
class Meta:
|
||||||
table_name = "lxmf_user_icons"
|
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(timezone.utc)) # noqa: F405
|
||||||
|
updated_at = DateTimeField(default=lambda: datetime.now(timezone.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(timezone.utc)) # noqa: F405
|
||||||
|
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc)) # noqa: F405
|
||||||
|
|
||||||
|
# define table name
|
||||||
|
class Meta:
|
||||||
|
table_name = "spam_keywords"
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
reticulum-meshchat:
|
reticulum-meshchat:
|
||||||
container_name: reticulum-meshchat
|
container_name: reticulum-meshchat
|
||||||
image: ghcr.io/liamcottle/reticulum-meshchat:latest
|
image: ghcr.io/sudo-ivan/reticulum-meshchat:latest
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# 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
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: 3FPBfiEwioWHFix3kZqe5bdU9F5o8mG8dh
|
|
||||||
- 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)
|
|
||||||
169
electron/main.js
169
electron/main.js
@@ -22,6 +22,27 @@ ipcMain.handle('alert', async(event, message) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// add support for showing a confirm window via ipc
|
||||||
|
ipcMain.handle('confirm', async(event, message) => {
|
||||||
|
|
||||||
|
// show confirm dialog
|
||||||
|
const result = await dialog.showMessageBox(mainWindow, {
|
||||||
|
type: "question",
|
||||||
|
title: "Confirm",
|
||||||
|
message: message,
|
||||||
|
cancelId: 0, // esc key should press cancel button
|
||||||
|
defaultId: 1, // enter key should press ok button
|
||||||
|
buttons: [
|
||||||
|
"Cancel", // 0
|
||||||
|
"OK", // 1
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// check if user clicked OK
|
||||||
|
return result.response === 1;
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
// add support for showing a prompt window via ipc
|
// add support for showing a prompt window via ipc
|
||||||
ipcMain.handle('prompt', async(event, message) => {
|
ipcMain.handle('prompt', async(event, message) => {
|
||||||
return await electronPrompt({
|
return await electronPrompt({
|
||||||
@@ -48,6 +69,9 @@ ipcMain.handle('showPathInFolder', (event, path) => {
|
|||||||
|
|
||||||
function log(message) {
|
function log(message) {
|
||||||
|
|
||||||
|
// log to stdout of this process
|
||||||
|
console.log(message);
|
||||||
|
|
||||||
// make sure main window exists
|
// make sure main window exists
|
||||||
if(!mainWindow){
|
if(!mainWindow){
|
||||||
return;
|
return;
|
||||||
@@ -58,9 +82,6 @@ function log(message) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// log to electron console
|
|
||||||
console.log(message);
|
|
||||||
|
|
||||||
// log to web console
|
// log to web console
|
||||||
mainWindow.webContents.send('log', message);
|
mainWindow.webContents.send('log', message);
|
||||||
|
|
||||||
@@ -98,70 +119,116 @@ function getDefaultReticulumConfigDir() {
|
|||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
|
||||||
// create browser window
|
// get arguments passed to application, and remove the provided application path
|
||||||
mainWindow = new BrowserWindow({
|
const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"];
|
||||||
width: 1500,
|
const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg));
|
||||||
height: 800,
|
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
|
||||||
webPreferences: {
|
|
||||||
// used to inject logging over ipc
|
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// open external links in default web browser instead of electron
|
if(!shouldLaunchHeadless){
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
||||||
|
|
||||||
var shouldShowInNewElectronWindow = false;
|
// create browser window
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1500,
|
||||||
|
height: 800,
|
||||||
|
webPreferences: {
|
||||||
|
// used to inject logging over ipc
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// we want to open call.html in a new electron window
|
// open external links in default web browser instead of electron
|
||||||
// but all other target="_blank" links should open in the system web browser
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
|
|
||||||
if(url.startsWith("http://localhost") && url.includes("/call.html")){
|
|
||||||
shouldShowInNewElectronWindow = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// we want to open blob urls in a new electron window
|
var shouldShowInNewElectronWindow = false;
|
||||||
else if(url.startsWith("blob:")) {
|
|
||||||
shouldShowInNewElectronWindow = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// open in new electron window
|
// we want to open call.html in a new electron window
|
||||||
if(shouldShowInNewElectronWindow){
|
// but all other target="_blank" links should open in the system web browser
|
||||||
|
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
|
||||||
|
if(url.startsWith("http://localhost") && url.includes("/call.html")){
|
||||||
|
shouldShowInNewElectronWindow = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we want to open blob urls in a new electron window
|
||||||
|
else if(url.startsWith("blob:")) {
|
||||||
|
shouldShowInNewElectronWindow = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// open in new electron window
|
||||||
|
if(shouldShowInNewElectronWindow){
|
||||||
|
return {
|
||||||
|
action: "allow",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback to opening any other url in external browser
|
||||||
|
shell.openExternal(url);
|
||||||
return {
|
return {
|
||||||
action: "allow",
|
action: "deny",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// navigate to loading page
|
||||||
|
await mainWindow.loadFile(path.join(__dirname, 'loading.html'));
|
||||||
|
|
||||||
|
// ask mac users for microphone access for audio calls to work
|
||||||
|
if(process.platform === "darwin"){
|
||||||
|
await systemPreferences.askForMediaAccess('microphone');
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback to opening any other url in external browser
|
}
|
||||||
shell.openExternal(url);
|
|
||||||
return {
|
|
||||||
action: "deny",
|
|
||||||
};
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
// navigate to loading page
|
|
||||||
await mainWindow.loadFile(path.join(__dirname, 'loading.html'));
|
|
||||||
|
|
||||||
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ask mac users for microphone access for audio calls to work
|
// verify executable exists
|
||||||
if(process.platform === "darwin"){
|
if(!exe || !fs.existsSync(exe)){
|
||||||
await systemPreferences.askForMediaAccess('microphone');
|
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 {
|
||||||
|
|
||||||
// get arguments passed to application, and remove the provided application path
|
|
||||||
const userProvidedArguments = process.argv.slice(1);
|
|
||||||
|
|
||||||
// arguments we always want to pass in
|
// arguments we always want to pass in
|
||||||
const requiredArguments = [
|
const requiredArguments = [
|
||||||
'--headless', // reticulum meshchat usually launches default web browser, we don't want this when using electron
|
'--headless', // reticulum meshchat usually launches default web browser, we don't want this when using electron
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ contextBridge.exposeInMainWorld('electron', {
|
|||||||
return await ipcRenderer.invoke('alert', message);
|
return await ipcRenderer.invoke('alert', message);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// show a confirm dialog in electron browser window, this fixes a bug where confirm breaks input fields on windows
|
||||||
|
confirm: async function(message) {
|
||||||
|
return await ipcRenderer.invoke('confirm', message);
|
||||||
|
},
|
||||||
|
|
||||||
// add support for using "prompt" in electron browser window
|
// add support for using "prompt" in electron browser window
|
||||||
prompt: async function(message) {
|
prompt: async function(message) {
|
||||||
return await ipcRenderer.invoke('prompt', message);
|
return await ipcRenderer.invoke('prompt', message);
|
||||||
|
|||||||
4130
meshchat.py
4130
meshchat.py
File diff suppressed because it is too large
Load Diff
2347
package-lock.json
generated
2347
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "reticulum-meshchat",
|
"name": "reticulum-meshchat",
|
||||||
"version": "1.17.0",
|
"version": "2.32.3",
|
||||||
"description": "",
|
"description": "A simple mesh network communications app powered by the Reticulum Network Stack",
|
||||||
|
"author": "Sudo-Ivan",
|
||||||
"main": "electron/main.js",
|
"main": "electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"watch": "npm run build-frontend -- --watch",
|
"watch": "npm run build-frontend -- --watch",
|
||||||
"build-frontend": "vite build",
|
"build-frontend": "vite build",
|
||||||
"build-backend": "python setup.py build",
|
"build-backend": "node scripts/build-backend.js",
|
||||||
"build": "npm run build-frontend && npm run build-backend",
|
"build": "npm run build-frontend && npm run build-backend",
|
||||||
"electron-postinstall": "electron-builder install-app-deps",
|
"electron-postinstall": "electron-builder install-app-deps",
|
||||||
"electron": "npm run electron-postinstall && npm run build && electron .",
|
"electron": "npm run electron-postinstall && npm run build && electron .",
|
||||||
@@ -17,13 +18,16 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^30.0.8",
|
"electron": "^39.2.4",
|
||||||
"electron-builder": "^24.6.3"
|
"electron-builder": "^24.6.3"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.liamcottle.reticulummeshchat",
|
"appId": "com.sudoivan.reticulummeshchat",
|
||||||
"productName": "Reticulum MeshChat",
|
"productName": "Reticulum MeshChatX",
|
||||||
"asar": false,
|
"asar": true,
|
||||||
|
"asarUnpack": [
|
||||||
|
"build/exe/**/*"
|
||||||
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"electron/**/*"
|
"electron/**/*"
|
||||||
],
|
],
|
||||||
@@ -70,7 +74,12 @@
|
|||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
|
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
|
||||||
"target": "AppImage",
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb"
|
||||||
|
],
|
||||||
|
"maintainer": "Sudo-Ivan",
|
||||||
|
"category": "Network",
|
||||||
"extraFiles": [
|
"extraFiles": [
|
||||||
{
|
{
|
||||||
"from": "build/exe",
|
"from": "build/exe",
|
||||||
@@ -98,10 +107,11 @@
|
|||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.12.0",
|
||||||
"click-outside-vue3": "^4.0.1",
|
"click-outside-vue3": "^4.0.1",
|
||||||
"compressorjs": "^1.2.1",
|
"compressorjs": "^1.2.1",
|
||||||
"electron-prompt": "^1.7.0",
|
"electron-prompt": "^1.7.0",
|
||||||
|
"micron-parser": "^1.0.2",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
@@ -109,7 +119,7 @@
|
|||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"vis-data": "^7.1.9",
|
"vis-data": "^7.1.9",
|
||||||
"vis-network": "^9.1.9",
|
"vis-network": "^9.1.9",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.4.1",
|
||||||
"vite-plugin-vuetify": "^2.0.4",
|
"vite-plugin-vuetify": "^2.0.4",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"vuetify": "^3.7.6"
|
"vuetify": "^3.7.6"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
aiohttp>=3.9.5
|
aiohttp>=3.12.14
|
||||||
cx_freeze>=7.0.0
|
cx_freeze>=7.0.0
|
||||||
lxmf>=0.5.8
|
lxmf>=0.9.3
|
||||||
peewee>=3.17.3
|
peewee>=3.18.1
|
||||||
rns>=0.8.8
|
psutil>=7.1.3
|
||||||
websockets>=12.0
|
rns>=1.0.4
|
||||||
|
websockets>=14.2
|
||||||
|
|||||||
18
scripts/build-backend.js
Executable file
18
scripts/build-backend.js
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const platform = os.platform();
|
||||||
|
const venvPython = platform === 'win32'
|
||||||
|
? path.join('venv', 'Scripts', 'python.exe')
|
||||||
|
: path.join('venv', 'bin', 'python');
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`${venvPython} setup.py build`, { stdio: 'inherit' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Build failed:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
46
setup.py
46
setup.py
@@ -1,44 +1,50 @@
|
|||||||
from cx_Freeze import setup, Executable
|
from cx_Freeze import Executable, setup
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='ReticulumMeshChat',
|
name="ReticulumMeshChatX",
|
||||||
version='1.0.0',
|
version="1.0.0",
|
||||||
description='A simple mesh network communications app powered by the Reticulum Network Stack',
|
description="A simple mesh network communications app powered by the Reticulum Network Stack",
|
||||||
executables=[
|
executables=[
|
||||||
Executable(
|
Executable(
|
||||||
script='meshchat.py', # this script to run
|
script="meshchat.py", # this script to run
|
||||||
base=None, # we are running a console application, not a gui
|
base=None, # we are running a console application, not a gui
|
||||||
target_name='ReticulumMeshChat', # creates ReticulumMeshChat.exe
|
target_name="ReticulumMeshChatX", # creates ReticulumMeshChatX.exe
|
||||||
shortcut_name='ReticulumMeshChat', # name shown in shortcut
|
shortcut_name="ReticulumMeshChatX", # name shown in shortcut
|
||||||
shortcut_dir='ProgramMenuFolder', # put the shortcut in windows start menu
|
shortcut_dir="ProgramMenuFolder", # put the shortcut in windows start menu
|
||||||
icon='logo/icon.ico', # set the icon for the exe
|
icon="logo/icon.ico", # set the icon for the exe
|
||||||
copyright='Copyright (c) 2024 Liam Cottle',
|
copyright="Copyright (c) 2024 Liam Cottle",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'build_exe': {
|
"build_exe": {
|
||||||
# libs that are required
|
# libs that are required
|
||||||
'packages': [
|
"packages": [
|
||||||
# required for dynamic import fix
|
# required for dynamic import fix
|
||||||
# https://github.com/marcelotduarte/cx_Freeze/discussions/2039
|
# https://github.com/marcelotduarte/cx_Freeze/discussions/2039
|
||||||
# https://github.com/marcelotduarte/cx_Freeze/issues/2041
|
# https://github.com/marcelotduarte/cx_Freeze/issues/2041
|
||||||
'RNS',
|
"RNS",
|
||||||
|
"RNS.Interfaces",
|
||||||
|
"LXMF",
|
||||||
],
|
],
|
||||||
# files that are required
|
# files that are required
|
||||||
'include_files': [
|
"include_files": [
|
||||||
'package.json', # used to determine app version from python
|
"package.json", # used to determine app version from python
|
||||||
'public/', # static files served by web server
|
"public/", # static files served by web server
|
||||||
],
|
],
|
||||||
# slim down the build by excluding these unused libs
|
# slim down the build by excluding these unused libs
|
||||||
'excludes': [
|
"excludes": [
|
||||||
'PIL', # saves ~200MB
|
"PIL", # saves ~200MB
|
||||||
],
|
],
|
||||||
# this has the same effect as the -O command line option when executing CPython directly.
|
# this has the same effect as the -O command line option when executing CPython directly.
|
||||||
# it also prevents assert statements from executing, removes docstrings and sets __debug__ to False.
|
# it also prevents assert statements from executing, removes docstrings and sets __debug__ to False.
|
||||||
# https://stackoverflow.com/a/57948104
|
# https://stackoverflow.com/a/57948104
|
||||||
"optimize": 2,
|
"optimize": 2,
|
||||||
# change where exe is built to
|
# change where exe is built to
|
||||||
'build_exe': 'build/exe',
|
"build_exe": "build/exe",
|
||||||
|
# make the build relocatable by replacing absolute paths
|
||||||
|
"replace_paths": [
|
||||||
|
("*", ""),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,16 +1,23 @@
|
|||||||
# an announce handler that forwards announces to a provided callback for the provided aspect filter
|
# an announce handler that forwards announces to a provided callback for the provided aspect filter
|
||||||
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
|
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
|
||||||
class AnnounceHandler:
|
class AnnounceHandler:
|
||||||
|
|
||||||
def __init__(self, aspect_filter: str, received_announce_callback):
|
def __init__(self, aspect_filter: str, received_announce_callback):
|
||||||
self.aspect_filter = aspect_filter
|
self.aspect_filter = aspect_filter
|
||||||
self.received_announce_callback = received_announce_callback
|
self.received_announce_callback = received_announce_callback
|
||||||
|
|
||||||
# we will just pass the received announce back to the provided callback
|
# we will just pass the received announce back to the provided callback
|
||||||
def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash):
|
def received_announce(
|
||||||
|
self, destination_hash, announced_identity, app_data, announce_packet_hash,
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
# handle received announce
|
# handle received announce
|
||||||
self.received_announce_callback(self.aspect_filter, destination_hash, announced_identity, app_data, announce_packet_hash)
|
self.received_announce_callback(
|
||||||
except:
|
self.aspect_filter,
|
||||||
|
destination_hash,
|
||||||
|
announced_identity,
|
||||||
|
app_data,
|
||||||
|
announce_packet_hash,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: E722
|
||||||
# ignore failure to handle received announce
|
# ignore failure to handle received announce
|
||||||
pass
|
pass
|
||||||
|
|||||||
23
src/backend/async_utils.py
Normal file
23
src/backend/async_utils.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import asyncio
|
||||||
|
from collections.abc import Coroutine
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncUtils:
|
||||||
|
# remember main loop
|
||||||
|
main_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set_main_loop(loop: asyncio.AbstractEventLoop):
|
||||||
|
AsyncUtils.main_loop = loop
|
||||||
|
|
||||||
|
# this method allows running the provided async coroutine from within a sync function
|
||||||
|
# it will run the async function on the main event loop if possible, otherwise it logs a warning
|
||||||
|
@staticmethod
|
||||||
|
def run_async(coroutine: Coroutine):
|
||||||
|
# run provided coroutine on main event loop, ensuring thread safety
|
||||||
|
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
|
||||||
|
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
|
||||||
|
return
|
||||||
|
|
||||||
|
# main event loop not running...
|
||||||
|
print("WARNING: Main event loop not available. Could not schedule task.")
|
||||||
@@ -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,24 @@ 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 +174,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 +196,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 +227,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: # noqa: E722
|
||||||
|
# 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
src/backend/interface_config_parser.py
Normal file
91
src/backend/interface_config_parser.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import RNS.vendor.configobj
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceConfigParser:
|
||||||
|
@staticmethod
|
||||||
|
def parse(text):
|
||||||
|
# get lines from provided text
|
||||||
|
lines = text.splitlines()
|
||||||
|
stripped_lines = [line.strip() for line in lines]
|
||||||
|
|
||||||
|
# ensure [interfaces] section exists
|
||||||
|
if "[interfaces]" not in stripped_lines:
|
||||||
|
lines.insert(0, "[interfaces]")
|
||||||
|
stripped_lines.insert(0, "[interfaces]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# parse lines as rns config object
|
||||||
|
config = RNS.vendor.configobj.ConfigObj(lines)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to parse interface config with ConfigObj: {e}")
|
||||||
|
return InterfaceConfigParser._parse_best_effort(lines)
|
||||||
|
|
||||||
|
# get interfaces from config
|
||||||
|
config_interfaces = config.get("interfaces", {})
|
||||||
|
if config_interfaces is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# process interfaces
|
||||||
|
interfaces = []
|
||||||
|
for interface_name in config_interfaces:
|
||||||
|
# ensure interface has a name
|
||||||
|
interface_config = config_interfaces[interface_name]
|
||||||
|
interface_config["name"] = interface_name
|
||||||
|
interfaces.append(interface_config)
|
||||||
|
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_best_effort(lines):
|
||||||
|
interfaces = []
|
||||||
|
current_interface_name = None
|
||||||
|
current_interface = {}
|
||||||
|
current_sub_name = None
|
||||||
|
current_sub = None
|
||||||
|
|
||||||
|
def commit_sub():
|
||||||
|
nonlocal current_sub_name, current_sub
|
||||||
|
if current_sub_name and current_sub is not None:
|
||||||
|
current_interface[current_sub_name] = current_sub
|
||||||
|
current_sub_name = None
|
||||||
|
current_sub = None
|
||||||
|
|
||||||
|
def commit_interface():
|
||||||
|
nonlocal current_interface_name, current_interface
|
||||||
|
if current_interface_name:
|
||||||
|
# shallow copy to avoid future mutation
|
||||||
|
interfaces.append(dict(current_interface))
|
||||||
|
current_interface_name = None
|
||||||
|
current_interface = {}
|
||||||
|
|
||||||
|
for raw_line in lines:
|
||||||
|
line = raw_line.strip()
|
||||||
|
if line == "" or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.lower() == "[interfaces]":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("[[[") and line.endswith("]]]"):
|
||||||
|
commit_sub()
|
||||||
|
current_sub_name = line[3:-3].strip()
|
||||||
|
current_sub = {}
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("[[") and line.endswith("]]"):
|
||||||
|
commit_sub()
|
||||||
|
commit_interface()
|
||||||
|
current_interface_name = line[2:-2].strip()
|
||||||
|
current_interface = {"name": current_interface_name}
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "=" in line and current_interface_name is not None:
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
target = current_sub if current_sub is not None else current_interface
|
||||||
|
target[key.strip()] = value.strip()
|
||||||
|
|
||||||
|
# commit any pending sections
|
||||||
|
commit_sub()
|
||||||
|
commit_interface()
|
||||||
|
|
||||||
|
return interfaces
|
||||||
11
src/backend/interface_editor.py
Normal file
11
src/backend/interface_editor.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
class InterfaceEditor:
|
||||||
|
@staticmethod
|
||||||
|
def update_value(interface_details: dict, data: dict, key: str):
|
||||||
|
# update value if provided and not empty
|
||||||
|
value = data.get(key)
|
||||||
|
if value is not None and value != "":
|
||||||
|
interface_details[key] = value
|
||||||
|
return
|
||||||
|
|
||||||
|
# otherwise remove existing value
|
||||||
|
interface_details.pop(key, None)
|
||||||
132
src/backend/interfaces/WebsocketClientInterface.py
Normal file
132
src/backend/interfaces/WebsocketClientInterface.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import RNS
|
||||||
|
from RNS.Interfaces.Interface import Interface
|
||||||
|
from websockets.sync.client import connect
|
||||||
|
from websockets.sync.connection import Connection
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketClientInterface(Interface):
|
||||||
|
# TODO: required?
|
||||||
|
DEFAULT_IFAC_SIZE = 16
|
||||||
|
|
||||||
|
RECONNECT_DELAY_SECONDS = 5
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
|
||||||
|
|
||||||
|
def __init__(self, owner, configuration, websocket: Connection = None):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.owner = owner
|
||||||
|
self.parent_interface = None
|
||||||
|
|
||||||
|
self.IN = True
|
||||||
|
self.OUT = False
|
||||||
|
self.HW_MTU = 262144 # 256KiB
|
||||||
|
self.bitrate = 1_000_000_000 # 1Gbps
|
||||||
|
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||||
|
|
||||||
|
# parse config
|
||||||
|
ifconf = Interface.get_config_obj(configuration)
|
||||||
|
self.name = ifconf.get("name")
|
||||||
|
self.target_url = ifconf.get("target_url", None)
|
||||||
|
|
||||||
|
# ensure target url is provided
|
||||||
|
if self.target_url is None:
|
||||||
|
raise SystemError(f"target_url is required for interface '{self.name}'")
|
||||||
|
|
||||||
|
# connect to websocket server if an existing connection was not provided
|
||||||
|
self.websocket = websocket
|
||||||
|
if self.websocket is None:
|
||||||
|
thread = threading.Thread(target=self.connect)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
# called when a full packet has been received over the websocket
|
||||||
|
def process_incoming(self, data):
|
||||||
|
# do nothing if offline or detached
|
||||||
|
if not self.online or self.detached:
|
||||||
|
return
|
||||||
|
|
||||||
|
# update received bytes counter
|
||||||
|
self.rxb += len(data)
|
||||||
|
|
||||||
|
# update received bytes counter for parent interface
|
||||||
|
if self.parent_interface is not None:
|
||||||
|
self.parent_interface.rxb += len(data)
|
||||||
|
|
||||||
|
# send received data to transport instance
|
||||||
|
self.owner.inbound(data, self)
|
||||||
|
|
||||||
|
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
|
||||||
|
def process_outgoing(self, data):
|
||||||
|
# do nothing if offline or detached
|
||||||
|
if not self.online or self.detached:
|
||||||
|
return
|
||||||
|
|
||||||
|
# send to websocket server
|
||||||
|
try:
|
||||||
|
self.websocket.send(data)
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log(
|
||||||
|
f"Exception occurred while transmitting via {self!s}", RNS.LOG_ERROR,
|
||||||
|
)
|
||||||
|
RNS.log(f"The contained exception was: {e!s}", RNS.LOG_ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
# update sent bytes counter
|
||||||
|
self.txb += len(data)
|
||||||
|
|
||||||
|
# update received bytes counter for parent interface
|
||||||
|
if self.parent_interface is not None:
|
||||||
|
self.parent_interface.txb += len(data)
|
||||||
|
|
||||||
|
# connect to the configured websocket server
|
||||||
|
def connect(self):
|
||||||
|
# do nothing if interface is detached
|
||||||
|
if self.detached:
|
||||||
|
return
|
||||||
|
|
||||||
|
# connect to websocket server
|
||||||
|
try:
|
||||||
|
RNS.log(f"Connecting to Websocket for {self!s}...", RNS.LOG_DEBUG)
|
||||||
|
self.websocket = connect(
|
||||||
|
f"{self.target_url}", max_size=None, compression=None,
|
||||||
|
)
|
||||||
|
RNS.log(f"Connected to Websocket for {self!s}", RNS.LOG_DEBUG)
|
||||||
|
self.read_loop()
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
# auto reconnect after delay
|
||||||
|
RNS.log(f"Websocket disconnected for {self!s}...", RNS.LOG_DEBUG)
|
||||||
|
time.sleep(self.RECONNECT_DELAY_SECONDS)
|
||||||
|
self.connect()
|
||||||
|
|
||||||
|
def read_loop(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
for message in self.websocket:
|
||||||
|
self.process_incoming(message)
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log(f"{self} read loop error: {e}", RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
self.online = False
|
||||||
|
|
||||||
|
def detach(self):
|
||||||
|
# mark as offline
|
||||||
|
self.online = False
|
||||||
|
|
||||||
|
# close websocket
|
||||||
|
if self.websocket is not None:
|
||||||
|
self.websocket.close()
|
||||||
|
|
||||||
|
# mark as detached
|
||||||
|
self.detached = True
|
||||||
|
|
||||||
|
|
||||||
|
# set interface class RNS should use when importing this external interface
|
||||||
|
interface_class = WebsocketClientInterface
|
||||||
164
src/backend/interfaces/WebsocketServerInterface.py
Normal file
164
src/backend/interfaces/WebsocketServerInterface.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import RNS
|
||||||
|
from RNS.Interfaces.Interface import Interface
|
||||||
|
from websockets.sync.server import Server, ServerConnection, serve
|
||||||
|
|
||||||
|
from src.backend.interfaces.WebsocketClientInterface import WebsocketClientInterface
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketServerInterface(Interface):
|
||||||
|
# TODO: required?
|
||||||
|
DEFAULT_IFAC_SIZE = 16
|
||||||
|
|
||||||
|
RESTART_DELAY_SECONDS = 5
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, owner, configuration):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.owner = owner
|
||||||
|
|
||||||
|
self.IN = True
|
||||||
|
self.OUT = False
|
||||||
|
self.HW_MTU = 262144 # 256KiB
|
||||||
|
self.bitrate = 1_000_000_000 # 1Gbps
|
||||||
|
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||||
|
|
||||||
|
self.server: Server | None = None
|
||||||
|
self.spawned_interfaces: [WebsocketClientInterface] = []
|
||||||
|
|
||||||
|
# parse config
|
||||||
|
ifconf = Interface.get_config_obj(configuration)
|
||||||
|
self.name = ifconf.get("name")
|
||||||
|
self.listen_ip = ifconf.get("listen_ip", None)
|
||||||
|
self.listen_port = ifconf.get("listen_port", None)
|
||||||
|
|
||||||
|
# ensure listen ip is provided
|
||||||
|
if self.listen_ip is None:
|
||||||
|
raise SystemError(f"listen_ip is required for interface '{self.name}'")
|
||||||
|
|
||||||
|
# ensure listen port is provided
|
||||||
|
if self.listen_port is None:
|
||||||
|
raise SystemError(f"listen_port is required for interface '{self.name}'")
|
||||||
|
|
||||||
|
# convert listen port to int
|
||||||
|
self.listen_port = int(self.listen_port)
|
||||||
|
|
||||||
|
# run websocket server
|
||||||
|
thread = threading.Thread(target=self.serve)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clients(self):
|
||||||
|
return len(self.spawned_interfaces)
|
||||||
|
|
||||||
|
# TODO docs
|
||||||
|
def received_announce(self, from_spawned=False):
|
||||||
|
if from_spawned:
|
||||||
|
self.ia_freq_deque.append(time.time())
|
||||||
|
|
||||||
|
# TODO docs
|
||||||
|
def sent_announce(self, from_spawned=False):
|
||||||
|
if from_spawned:
|
||||||
|
self.oa_freq_deque.append(time.time())
|
||||||
|
|
||||||
|
# do nothing as the spawned child interface will take care of rx/tx
|
||||||
|
def process_incoming(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# do nothing as the spawned child interface will take care of rx/tx
|
||||||
|
def process_outgoing(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def serve(self):
|
||||||
|
# handle new websocket client connections
|
||||||
|
def on_websocket_client_connected(websocket: ServerConnection):
|
||||||
|
# create new child interface
|
||||||
|
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
|
||||||
|
spawned_interface = WebsocketClientInterface(
|
||||||
|
self.owner,
|
||||||
|
{
|
||||||
|
"name": f"Client on {self.name}",
|
||||||
|
"target_host": websocket.remote_address[0],
|
||||||
|
"target_port": str(websocket.remote_address[1]),
|
||||||
|
},
|
||||||
|
websocket=websocket,
|
||||||
|
)
|
||||||
|
|
||||||
|
# configure child interface
|
||||||
|
spawned_interface.IN = self.IN
|
||||||
|
spawned_interface.OUT = self.OUT
|
||||||
|
spawned_interface.HW_MTU = self.HW_MTU
|
||||||
|
spawned_interface.bitrate = self.bitrate
|
||||||
|
spawned_interface.mode = self.mode
|
||||||
|
spawned_interface.parent_interface = self
|
||||||
|
spawned_interface.online = True
|
||||||
|
|
||||||
|
# TODO implement?
|
||||||
|
spawned_interface.announce_rate_target = None
|
||||||
|
spawned_interface.announce_rate_grace = None
|
||||||
|
spawned_interface.announce_rate_penalty = None
|
||||||
|
|
||||||
|
# TODO ifac?
|
||||||
|
# TODO announce rates?
|
||||||
|
|
||||||
|
# activate child interface
|
||||||
|
RNS.log(
|
||||||
|
f"Spawned new WebsocketClientInterface: {spawned_interface}",
|
||||||
|
RNS.LOG_VERBOSE,
|
||||||
|
)
|
||||||
|
RNS.Transport.interfaces.append(spawned_interface)
|
||||||
|
|
||||||
|
# associate child interface with this interface
|
||||||
|
while spawned_interface in self.spawned_interfaces:
|
||||||
|
self.spawned_interfaces.remove(spawned_interface)
|
||||||
|
self.spawned_interfaces.append(spawned_interface)
|
||||||
|
|
||||||
|
# run read loop
|
||||||
|
spawned_interface.read_loop()
|
||||||
|
|
||||||
|
# client must have disconnected as the read loop finished, so forget the spawned interface
|
||||||
|
self.spawned_interfaces.remove(spawned_interface)
|
||||||
|
|
||||||
|
# run websocket server
|
||||||
|
try:
|
||||||
|
RNS.log(f"Starting Websocket server for {self!s}...", RNS.LOG_DEBUG)
|
||||||
|
with serve(
|
||||||
|
on_websocket_client_connected,
|
||||||
|
self.listen_ip,
|
||||||
|
self.listen_port,
|
||||||
|
compression=None,
|
||||||
|
) as server:
|
||||||
|
self.online = True
|
||||||
|
self.server = server
|
||||||
|
server.serve_forever()
|
||||||
|
except Exception as e:
|
||||||
|
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
|
||||||
|
|
||||||
|
# websocket server is no longer running, let's restart it
|
||||||
|
self.online = False
|
||||||
|
RNS.log(f"Websocket server stopped for {self!s}...", RNS.LOG_DEBUG)
|
||||||
|
time.sleep(self.RESTART_DELAY_SECONDS)
|
||||||
|
self.serve()
|
||||||
|
|
||||||
|
def detach(self):
|
||||||
|
# mark as offline
|
||||||
|
self.online = False
|
||||||
|
|
||||||
|
# stop websocket server
|
||||||
|
if self.server is not None:
|
||||||
|
self.server.shutdown()
|
||||||
|
|
||||||
|
# mark as detached
|
||||||
|
self.detached = True
|
||||||
|
|
||||||
|
|
||||||
|
# set interface class RNS should use when importing this external interface
|
||||||
|
interface_class = WebsocketServerInterface
|
||||||
@@ -1,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
|
||||||
|
|
||||||
|
|||||||
3
src/backend/sideband_commands.py
Normal file
3
src/backend/sideband_commands.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://github.com/markqvist/Sideband/blob/e515889e210037f881c201e0d627a7b09a48eb69/sbapp/sideband/sense.py#L11
|
||||||
|
class SidebandCommands:
|
||||||
|
TELEMETRY_REQUEST = 0x01
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {createApp} from 'vue';
|
import { createApp } from 'vue';
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
import CallPage from "./components/call/CallPage.vue";
|
import CallPage from "./components/call/CallPage.vue";
|
||||||
|
import { ensureCodec2ScriptsLoaded } from "./js/Codec2Loader";
|
||||||
|
|
||||||
// provide axios globally
|
// provide axios globally
|
||||||
window.axios = axios;
|
window.axios = axios;
|
||||||
|
|
||||||
createApp(CallPage)
|
async function bootstrap() {
|
||||||
.mount('#app');
|
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){
|
||||||
@@ -448,7 +523,7 @@ export default {
|
|||||||
|
|
||||||
// ask to stop syncing if already syncing
|
// ask to stop syncing if already syncing
|
||||||
if(this.isSyncingPropagationNode){
|
if(this.isSyncingPropagationNode){
|
||||||
if(confirm("Are you sure you want to stop syncing?")){
|
if(await DialogUtils.confirm("Are you sure you want to stop syncing?")){
|
||||||
await this.stopSyncingPropagationNode();
|
await this.stopSyncingPropagationNode();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -529,7 +604,7 @@ export default {
|
|||||||
async hangupAllCalls() {
|
async hangupAllCalls() {
|
||||||
|
|
||||||
// confirm user wants to hang up calls
|
// confirm user wants to hang up calls
|
||||||
if(!confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
|
if(!await DialogUtils.confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
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" @click.stop="hideMenu" class="overflow-hidden absolute right-0 z-10 mr-4 w-56 rounded-md bg-white shadow-md border border-gray-200 focus:outline-none" :class="[ dropdownClass ]">
|
<div v-if="isShowingMenu" @click.stop="hideMenu" class="overflow-hidden absolute right-0 z-10 mr-4 w-56 rounded-md bg-white shadow-md border border-gray-200 focus:outline-none dark:border-zinc-700" :class="[ dropdownClass ]">
|
||||||
<slot name="items"/>
|
<slot name="items"/>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="cursor-pointer flex p-3 space-x-2 text-sm text-gray-500 hover:bg-gray-100">
|
<div class="cursor-pointer flex p-3 space-x-2 text-sm bg-white text-gray-500 hover:bg-gray-100 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700">
|
||||||
<slot/>
|
<slot/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<button type="button" class="p-2 rounded-full text-gray-700 bg-gray-100 hover:bg-gray-200">
|
<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>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<RouterLink :to="to" v-slot="{ href, route, navigate, isActive, isExactActive }" custom>
|
<RouterLink :to="to" v-slot="{ href, route, navigate, isActive }" custom>
|
||||||
<a
|
<a
|
||||||
:href="href"
|
:href="href"
|
||||||
@click="handleNavigate($event, navigate)"
|
@click="handleNavigate($event, navigate)"
|
||||||
type="button"
|
type="button"
|
||||||
:class="[
|
:class="[
|
||||||
isExactActive
|
isActive
|
||||||
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
|
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
|
||||||
: 'hover:bg-gray-100 dark:hover:bg-zinc-700'
|
: 'hover:bg-gray-100 dark:hover:bg-zinc-700'
|
||||||
]"
|
]"
|
||||||
|
|||||||
@@ -1,118 +1,200 @@
|
|||||||
<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 space-y-2 p-2">
|
<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">
|
||||||
|
|
||||||
<!-- app info -->
|
<div v-if="appInfo" class="glass-card">
|
||||||
<div v-if="appInfo" class="bg-white dark:bg-zinc-900 rounded shadow">
|
<div class="flex flex-col gap-4 md:flex-row md:items-center">
|
||||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">App Info</div>
|
<div class="flex-1 space-y-2">
|
||||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
<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>
|
||||||
|
|
||||||
<!-- version -->
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<div class="flex p-1">
|
<div v-if="appInfo?.memory_usage" class="glass-card space-y-3">
|
||||||
<div class="mr-auto">
|
<header class="flex items-center gap-2">
|
||||||
<div>Versions</div>
|
<MaterialDesignIcon icon-name="chip" class="w-5 h-5 text-blue-500"/>
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
<div>
|
||||||
MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden sm:block mx-2 my-auto">
|
</header>
|
||||||
<a target="_blank"
|
<div class="metric-row">
|
||||||
href="https://github.com/liamcottle/reticulum-meshchat/releases"
|
<div>
|
||||||
type="button"
|
<div class="glass-label">Memory (RSS)</div>
|
||||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 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">
|
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.rss) }}</div>
|
||||||
Check for Updates
|
</div>
|
||||||
</a>
|
<div>
|
||||||
|
<div class="glass-label">Virtual Memory</div>
|
||||||
|
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.vms) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- reticulum config path -->
|
|
||||||
<div class="flex p-1">
|
|
||||||
<div class="mr-auto">
|
|
||||||
<div>Reticulum Config Path</div>
|
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400 break-all">{{ appInfo.reticulum_config_path }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isElectron" class="mx-2 my-auto">
|
|
||||||
<button @click="showReticulumConfigFile"
|
|
||||||
type="button"
|
|
||||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 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">
|
|
||||||
Show in Folder
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- database path -->
|
|
||||||
<div class="flex p-1">
|
|
||||||
<div class="mr-auto">
|
|
||||||
<div>Database Path</div>
|
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400 break-all">{{ appInfo.database_path }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isElectron" class="mx-2 my-auto">
|
|
||||||
<button @click="showDatabaseFile"
|
|
||||||
type="button"
|
|
||||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 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">
|
|
||||||
Show in Folder
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- database file size -->
|
|
||||||
<div class="p-1">
|
|
||||||
<div>Database File Size</div>
|
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.database_file_size) }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- reticulum status -->
|
<div v-if="appInfo?.network_stats" class="glass-card space-y-3">
|
||||||
<div v-if="appInfo" class="bg-white dark:bg-zinc-900 rounded shadow">
|
<header class="flex items-center gap-2">
|
||||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">Reticulum Status</div>
|
<MaterialDesignIcon icon-name="access-point-network" class="w-5 h-5 text-purple-500"/>
|
||||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-white">Network Stats</div>
|
||||||
<!-- instance mode -->
|
<div class="text-xs text-emerald-500 flex items-center gap-1">
|
||||||
<div class="p-1">
|
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||||
<div>Instance Mode</div>
|
Live
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
</div>
|
||||||
<span v-if="appInfo.is_connected_to_shared_instance" class="text-orange-600 dark:text-orange-400">Connected to Shared Instance</span>
|
</div>
|
||||||
<span v-else class="text-green-600 dark:text-green-400">Running as Standalone Instance</span>
|
</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>
|
</div>
|
||||||
|
<div class="metric-row">
|
||||||
<!-- transport mode -->
|
<div>
|
||||||
<div class="p-1">
|
<div class="glass-label">Packets Sent</div>
|
||||||
<div>Transport Mode</div>
|
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_sent) }}</div>
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
</div>
|
||||||
<span v-if="appInfo.is_transport_enabled" class="text-green-600 dark:text-green-400">Transport Enabled</span>
|
<div>
|
||||||
<span v-else class="text-orange-600 dark:text-orange-400">Transport Disabled</span>
|
<div class="glass-label">Packets Received</div>
|
||||||
|
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_recv) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- my addresses -->
|
<div v-if="appInfo?.reticulum_stats" class="glass-card space-y-3">
|
||||||
<div v-if="config" class="bg-white dark:bg-zinc-900 rounded shadow">
|
<header class="flex items-center gap-2">
|
||||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">My Addresses</div>
|
<MaterialDesignIcon icon-name="diagram-projector" class="w-5 h-5 text-indigo-500"/>
|
||||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
<div>
|
||||||
<div class="p-1">
|
<div class="text-lg font-semibold text-gray-900 dark:text-white">Reticulum Stats</div>
|
||||||
<div>Identity Hash</div>
|
<div class="text-xs text-emerald-500 flex items-center gap-1">
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.identity_hash }}</div>
|
<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 class="p-1">
|
</div>
|
||||||
<div>LXMF Address</div>
|
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.lxmf_address_hash }}</div>
|
<div v-if="appInfo?.download_stats" class="glass-card space-y-3">
|
||||||
</div>
|
<header class="flex items-center gap-2">
|
||||||
<div class="p-1">
|
<MaterialDesignIcon icon-name="download" class="w-5 h-5 text-sky-500"/>
|
||||||
<div>LXMF Propagation Node Address</div>
|
<div>
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.lxmf_local_propagation_node_address_hash }}</div>
|
<div class="text-lg font-semibold text-gray-900 dark:text-white">Download Activity</div>
|
||||||
</div>
|
<div class="text-xs text-emerald-500 flex items-center gap-1">
|
||||||
<div class="p-1">
|
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||||
<div>Audio Call Address</div>
|
Live
|
||||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.audio_call_address_hash }}</div>
|
</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>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -120,17 +202,32 @@
|
|||||||
<script>
|
<script>
|
||||||
import Utils from "../../js/Utils";
|
import Utils from "../../js/Utils";
|
||||||
import ElectronUtils from "../../js/ElectronUtils";
|
import ElectronUtils from "../../js/ElectronUtils";
|
||||||
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
export default {
|
export default {
|
||||||
name: 'AboutPage',
|
name: 'AboutPage',
|
||||||
|
components: {
|
||||||
|
MaterialDesignIcon,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
appInfo: null,
|
appInfo: null,
|
||||||
config: null,
|
config: null,
|
||||||
|
updateInterval: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.getAppInfo();
|
this.getAppInfo();
|
||||||
this.getConfig();
|
this.getConfig();
|
||||||
|
// Update stats every 5 seconds
|
||||||
|
this.updateInterval = setInterval(() => {
|
||||||
|
this.getAppInfo();
|
||||||
|
}, 5000);
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.updateInterval) {
|
||||||
|
clearInterval(this.updateInterval);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async getAppInfo() {
|
async getAppInfo() {
|
||||||
@@ -151,6 +248,20 @@ export default {
|
|||||||
console.log(e);
|
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() {
|
showReticulumConfigFile() {
|
||||||
const reticulumConfigPath = this.appInfo.reticulum_config_path;
|
const reticulumConfigPath = this.appInfo.reticulum_config_path;
|
||||||
if(reticulumConfigPath){
|
if(reticulumConfigPath){
|
||||||
@@ -166,6 +277,17 @@ export default {
|
|||||||
formatBytes: function(bytes) {
|
formatBytes: function(bytes) {
|
||||||
return Utils.formatBytes(bytes);
|
return Utils.formatBytes(bytes);
|
||||||
},
|
},
|
||||||
|
formatNumber: function(num) {
|
||||||
|
return Utils.formatNumber(num);
|
||||||
|
},
|
||||||
|
formatBytesPerSecond: function(bytesPerSecond) {
|
||||||
|
return Utils.formatBytesPerSecond(bytesPerSecond);
|
||||||
|
},
|
||||||
|
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: {
|
computed: {
|
||||||
isElectron() {
|
isElectron() {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -259,6 +254,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import protobuf from "protobufjs";
|
import protobuf from "protobufjs";
|
||||||
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
export default {
|
export default {
|
||||||
name: 'CallPage',
|
name: 'CallPage',
|
||||||
data() {
|
data() {
|
||||||
@@ -488,7 +484,7 @@ export default {
|
|||||||
async hangupCall(callHash) {
|
async hangupCall(callHash) {
|
||||||
|
|
||||||
// confirm user wants to hang up call
|
// confirm user wants to hang up call
|
||||||
if(!confirm("Are you sure you want to hang up this call?")){
|
if(!await DialogUtils.confirm("Are you sure you want to hang up this call?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,7 +677,7 @@ export default {
|
|||||||
async deleteCall(callHash) {
|
async deleteCall(callHash) {
|
||||||
|
|
||||||
// confirm user wants to delete call
|
// confirm user wants to delete call
|
||||||
if(!confirm("Are you sure you want to delete this call?")){
|
if(!await DialogUtils.confirm("Are you sure you want to delete this call?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +697,7 @@ export default {
|
|||||||
async clearCallHistory() {
|
async clearCallHistory() {
|
||||||
|
|
||||||
// confirm user wants to clear call history
|
// confirm user wants to clear call history
|
||||||
if(!confirm("Are you sure you want to clear your call history?")){
|
if(!await DialogUtils.confirm("Are you sure you want to clear your call history?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
src/frontend/components/forms/FormLabel.vue
Normal file
10
src/frontend/components/forms/FormLabel.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<label class="block text-sm font-medium text-gray-900 dark:text-zinc-100">
|
||||||
|
<slot/>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'FormLabel',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
10
src/frontend/components/forms/FormSubLabel.vue
Normal file
10
src/frontend/components/forms/FormSubLabel.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-xs text-gray-600 dark:text-zinc-300">
|
||||||
|
<slot/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'FormSubLabel',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
35
src/frontend/components/interfaces/ExpandingSection.vue
Normal file
35
src/frontend/components/interfaces/ExpandingSection.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded shadow divide-y divide-gray-300 dark:divide-zinc-700 dark:bg-zinc-900 overflow-hidden">
|
||||||
|
<div @click="isExpanded = !isExpanded" class="flex p-2 justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800">
|
||||||
|
<div class="my-auto mr-auto">
|
||||||
|
<div class="font-bold dark:text-white">
|
||||||
|
<slot name="title"/>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<slot name="subtitle"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="my-auto ml-2">
|
||||||
|
<div class="w-5 h-5 text-gray-600 dark:text-gray-300 transform transition-transform duration-200" :class="{ 'rotate-90': isExpanded }">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="size-5">
|
||||||
|
<rect width="256" height="256" fill="none"/>
|
||||||
|
<path d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isExpanded" class="divide-y divide-gray-200 dark:text-white">
|
||||||
|
<slot name="content"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'ExpandingSection',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isExpanded: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="isShowing" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity flex items-center justify-center">
|
<div v-if="isShowing" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity flex items-center justify-center">
|
||||||
<div class="flex w-full h-full p-4 overflow-y-auto">
|
<div class="flex w-full h-full p-4 overflow-y-auto">
|
||||||
<div class="my-auto mx-auto w-full bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl">
|
<div v-click-outside="dismiss" class="my-auto mx-auto w-full bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl">
|
||||||
|
|
||||||
<!-- title -->
|
<!-- title -->
|
||||||
<div class="p-4 border-b dark:border-zinc-700">
|
<div class="p-4 border-b dark:border-zinc-700">
|
||||||
@@ -13,16 +13,14 @@
|
|||||||
|
|
||||||
<!-- file input -->
|
<!-- file input -->
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<div class="text-sm font-medium text-gray-700 dark:text-zinc-200">Select a Configuration File</div>
|
|
||||||
<div>
|
<div>
|
||||||
<input ref="import-interfaces-file-input" type="file" @change="onFileSelected" accept="*"
|
<input ref="import-interfaces-file-input" type="file" @change="onFileSelected" accept="*" class="w-full text-sm text-gray-500 dark:text-zinc-400">
|
||||||
class="mt-1 block w-full text-sm text-gray-500 dark:text-zinc-400
|
</div>
|
||||||
file:mr-4 file:py-2 file:px-4
|
<div v-if="!selectedFile" class="mt-2 text-sm text-gray-700 dark:text-zinc-200">
|
||||||
file:rounded-md file:border-0
|
<ul class="list-disc list-inside">
|
||||||
file:text-sm file:font-semibold
|
<li>You can import interfaces from a ~/.reticulum/config file.</li>
|
||||||
file:bg-gray-500 file:text-white
|
<li>You can import interfaces from an exported interfaces file.</li>
|
||||||
hover:file:bg-gray-400
|
</ul>
|
||||||
dark:file:bg-zinc-700 dark:hover:file:bg-zinc-600">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -35,11 +33,52 @@
|
|||||||
<button @click="deselectAllInterfaces" class="text-sm text-blue-500 hover:underline">Deselect All</button>
|
<button @click="deselectAllInterfaces" class="text-sm text-blue-500 hover:underline">Deselect All</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2 space-y-2 max-h-72 overflow-y-auto">
|
<div class="bg-gray-200 p-2 space-y-2 max-h-80 overflow-y-auto dark:bg-zinc-800">
|
||||||
<div @click="toggleSelectedInterface(iface.name)" v-for="iface in importableInterfaces" :key="iface.name" class="cursor-pointer flex items-center p-2 border rounded dark:border-zinc-700 shadow">
|
<div @click="toggleSelectedInterface(iface.name)" v-for="iface in importableInterfaces" :key="iface.name" class="bg-white cursor-pointer flex items-center p-2 border rounded shadow dark:bg-zinc-900 dark:border-zinc-700">
|
||||||
<div class="mr-auto text-sm text-gray-700 dark:text-zinc-200">
|
<div class="mr-auto text-sm">
|
||||||
<div class="font-semibold">{{ iface.name }}</div>
|
<div class="font-semibold text-gray-700 dark:text-zinc-100">{{ iface.name }}</div>
|
||||||
<div class="text-sm text-gray-500">{{ iface.type }}</div>
|
<div class="text-sm text-gray-500 dark:text-zinc-100">
|
||||||
|
|
||||||
|
<!-- auto interface -->
|
||||||
|
<div v-if="iface.type === 'AutoInterface'">
|
||||||
|
<div>{{ iface.type }}</div>
|
||||||
|
<div>Ethernet and WiFi</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- tcp client interface -->
|
||||||
|
<div v-else-if="iface.type === 'TCPClientInterface'">
|
||||||
|
<div>{{ iface.type }}</div>
|
||||||
|
<div>{{ iface.target_host }}:{{ iface.target_port }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- tcp server interface -->
|
||||||
|
<div v-else-if="iface.type === 'TCPServerInterface'">
|
||||||
|
<div>{{ iface.type }}</div>
|
||||||
|
<div>{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- udp interface -->
|
||||||
|
<div v-else-if="iface.type === 'UDPInterface'">
|
||||||
|
<div>{{ iface.type }}</div>
|
||||||
|
<div>Listen: {{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||||
|
<div>Forward: {{ iface.forward_ip }}:{{ iface.forward_port }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- rnode interface details -->
|
||||||
|
<div v-else-if="iface.type === 'RNodeInterface'">
|
||||||
|
<div>{{ iface.type }}</div>
|
||||||
|
<div>Port: {{ iface.port }}</div>
|
||||||
|
<div>Frequency: {{ formatFrequency(iface.frequency) }}</div>
|
||||||
|
<div>Bandwidth: {{ formatFrequency(iface.bandwidth) }}</div>
|
||||||
|
<div>Spreading Factor: {{ iface.spreadingfactor }}</div>
|
||||||
|
<div>Coding Rate: {{ iface.codingrate }}</div>
|
||||||
|
<div>Transmit Power: {{ iface.txpower }}dBm</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- other interface types -->
|
||||||
|
<div v-else>{{ iface.type }}</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input @click.stop type="checkbox" v-model="selectedInterfaces" :value="iface.name" class="mx-2 h-4 w-4 text-blue-600 rounded border-gray-300 dark:border-zinc-600">
|
<input @click.stop type="checkbox" v-model="selectedInterfaces" :value="iface.name" class="mx-2 h-4 w-4 text-blue-600 rounded border-gray-300 dark:border-zinc-600">
|
||||||
</div>
|
</div>
|
||||||
@@ -64,6 +103,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import DialogUtils from "../../js/DialogUtils";
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
|
import Utils from "../../js/Utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ImportInterfacesModal",
|
name: "ImportInterfacesModal",
|
||||||
@@ -85,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;
|
||||||
@@ -109,9 +150,9 @@ export default {
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
// fetch preview of interfaces to import
|
// fetch preview of interfaces to import
|
||||||
const formData = new FormData();
|
const response = await window.axios.post('/api/v1/reticulum/interfaces/import-preview', {
|
||||||
formData.append('config', file);
|
config: await file.text(),
|
||||||
const response = await window.axios.post('/api/v1/reticulum/interfaces/preview', formData);
|
});
|
||||||
|
|
||||||
// ensure there are some interfaces available to import
|
// ensure there are some interfaces available to import
|
||||||
if(!response.data.interfaces || response.data.interfaces.length === 0){
|
if(!response.data.interfaces || response.data.interfaces.length === 0){
|
||||||
@@ -172,18 +213,16 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// create form data to send to server
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('config', this.selectedFile);
|
|
||||||
formData.append('selected_interfaces', JSON.stringify(this.selectedInterfaces));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// import interfaces
|
// import interfaces
|
||||||
await window.axios.post('/api/v1/reticulum/interfaces/import', formData);
|
await window.axios.post('/api/v1/reticulum/interfaces/import', {
|
||||||
|
config: await this.selectedFile.text(),
|
||||||
|
selected_interface_names: this.selectedInterfaces,
|
||||||
|
});
|
||||||
|
|
||||||
// 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.");
|
||||||
@@ -194,6 +233,9 @@ export default {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
formatFrequency(hz) {
|
||||||
|
return Utils.formatFrequency(hz);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,156 +1,136 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="border rounded bg-white shadow overflow-hidden dark:bg-zinc-800 dark:border-zinc-700">
|
<div class="interface-card">
|
||||||
|
<div class="flex gap-4 items-start">
|
||||||
<!-- IFAC info -->
|
<div class="interface-card__icon">
|
||||||
<div v-if="iface._stats?.ifac_signature != null" class="bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-b dark:bg-zinc-800 dark:border-zinc-700">
|
<MaterialDesignIcon :icon-name="iconName" class="w-6 h-6"/>
|
||||||
<div class="flex text-sm">
|
</div>
|
||||||
<div class="my-auto">
|
<div class="flex-1 space-y-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4 text-green-500">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 0 0-4.5 4.5V9H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-.5V5.5A4.5 4.5 0 0 0 10 1Zm3 8V5.5a3 3 0 1 0-6 0V9h6Z" clip-rule="evenodd" />
|
<div class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ iface._name }}</div>
|
||||||
</svg>
|
<span class="type-chip">{{ iface.type }}</span>
|
||||||
|
<span :class="statusChipClass">{{ isInterfaceEnabled(iface) ? 'Enabled' : 'Disabled' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="ml-1 my-auto">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
<span class="text-green-500">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span> with sig <span @click="onIFACSignatureClick(iface._stats.ifac_signature)" class="cursor-pointer"><{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}></span>
|
{{ description }}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<div class="flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
</div>
|
<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>
|
||||||
<div class="flex py-2">
|
<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>
|
||||||
<!-- icon -->
|
<span class="stat-chip" v-if="iface._stats?.clients != null">Clients {{ iface._stats?.clients }}</span>
|
||||||
<div class="my-auto mx-2">
|
</div>
|
||||||
|
<div v-if="iface._stats?.ifac_signature" class="ifac-line">
|
||||||
<svg v-if="iface.type === 'AutoInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
<span class="text-emerald-500 font-semibold">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span>
|
||||||
<path d="M219.31,108.68l-80-80a16,16,0,0,0-22.62,0l-80,80A15.87,15.87,0,0,0,32,120v96a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V160h32v56a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V120A15.87,15.87,0,0,0,219.31,108.68ZM208,208H160V152a8,8,0,0,0-8-8H104a8,8,0,0,0-8,8v56H48V120l80-80,80,80Z"></path>
|
<span v-if="iface._stats?.ifac_netname">• {{ iface._stats.ifac_netname }}</span>
|
||||||
</svg>
|
<span>•</span>
|
||||||
|
<button @click="onIFACSignatureClick(iface._stats.ifac_signature)" type="button" class="text-blue-500 hover:underline">
|
||||||
<svg v-else-if="iface.type === 'RNodeInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
{{ iface._stats.ifac_signature.slice(0, 8) }}…{{ iface._stats.ifac_signature.slice(-8) }}
|
||||||
<path d="M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z"></path>
|
</button>
|
||||||
</svg>
|
|
||||||
|
|
||||||
<svg v-else-if="iface.type === 'TCPClientInterface' || iface.type === 'TCPServerInterface' || iface.type === 'UDPInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
|
||||||
<path d="M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm88,104a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.84a157.44,157.44,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154,88H102a115.11,115.11,0,0,1,26-45A115.27,115.27,0,0,1,154,88Zm52.33,0H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM107.59,42.4A135.28,135.28,0,0,0,85.29,88H49.63A88.29,88.29,0,0,1,107.59,42.4ZM49.63,168H85.29a135.28,135.28,0,0,0,22.3,45.6A88.29,88.29,0,0,1,49.63,168Zm98.78,45.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z"></path>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<svg v-else-if="iface.type === 'SerialInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
|
||||||
<path d="M252.44,121.34l-48-32A8,8,0,0,0,192,96v24H72V72h33a32,32,0,1,0,0-16H72A16,16,0,0,0,56,72v48H8a8,8,0,0,0,0,16H56v48a16,16,0,0,0,16,16h32v8a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H120a16,16,0,0,0-16,16v8H72V136H192v24a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM136,48a16,16,0,1,1-16,16A16,16,0,0,1,136,48ZM120,176h32v32H120Zm88-30.95V111l25.58,17Z"></path>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
|
||||||
<path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- interface details -->
|
|
||||||
<div>
|
|
||||||
<div class="font-semibold leading-5 dark:text-white">{{ iface._name }}</div>
|
|
||||||
<div class="text-sm flex space-x-1 dark:text-zinc-100">
|
|
||||||
|
|
||||||
<!-- auto interface -->
|
|
||||||
<span v-if="iface.type === 'AutoInterface'">
|
|
||||||
{{ iface.type }} • Ethernet and WiFi
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- tcp client interface -->
|
|
||||||
<span v-else-if="iface.type === 'TCPClientInterface'">
|
|
||||||
{{ iface.type }} • {{ iface.target_host }}:{{ iface.target_port }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- tcp server interface -->
|
|
||||||
<span v-else-if="iface.type === 'TCPServerInterface'">
|
|
||||||
{{ iface.type }} • {{ iface.listen_ip }}:{{ iface.listen_port }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- udp interface -->
|
|
||||||
<span v-else-if="iface.type === 'UDPInterface'">
|
|
||||||
{{ iface.type }} • {{ iface.listen_ip }}:{{ iface.listen_port }} • {{ iface.forward_ip }}:{{ iface.forward_port }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- rnode interface details -->
|
|
||||||
<span v-else-if="iface.type === 'RNodeInterface'">
|
|
||||||
{{ iface.type }} • {{ iface.port }} • freq={{ iface.frequency }} • bw={{ iface.bandwidth }} • power={{ iface.txpower }}dBm • sf={{ iface.spreadingfactor }} • cr={{ iface.codingrate }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- unknown interface types -->
|
|
||||||
<span v-else>
|
|
||||||
{{ iface.type ?? 'Unknown Interface Type' }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 items-end">
|
||||||
<!-- enabled state badge -->
|
<button
|
||||||
<div class="ml-auto my-auto mr-2">
|
v-if="isInterfaceEnabled(iface)"
|
||||||
<span v-if="isInterfaceEnabled(iface)" class="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Enabled</span>
|
@click="disableInterface"
|
||||||
<span v-else class="inline-flex items-center rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-600/20">Disabled</span>
|
type="button"
|
||||||
</div>
|
class="secondary-chip text-xs"
|
||||||
|
>
|
||||||
<!-- enable/disable interface button -->
|
<MaterialDesignIcon icon-name="power" class="w-4 h-4"/>
|
||||||
<div class="my-auto mr-1">
|
Disable
|
||||||
<button v-if="isInterfaceEnabled(iface)" @click="disableInterface" type="button" class="cursor-pointer">
|
|
||||||
<span class="flex 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">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="enableInterface" type="button" class="cursor-pointer">
|
<button
|
||||||
<span class="flex 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 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 rounded-full">
|
v-else
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
@click="enableInterface"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
type="button"
|
||||||
</svg>
|
class="primary-chip text-xs"
|
||||||
</span>
|
>
|
||||||
|
<MaterialDesignIcon icon-name="power" class="w-4 h-4"/>
|
||||||
|
Enable
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<!-- edit interface button -->
|
|
||||||
<div class="my-auto mr-1">
|
|
||||||
<button @click="editInterface" type="button" class="cursor-pointer">
|
|
||||||
<span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 rounded-full ">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- delete interface button -->
|
|
||||||
<div class="my-auto mr-2">
|
|
||||||
<button @click="deleteInterface" type="button" class="cursor-pointer">
|
|
||||||
<span class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 rounded-full">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-t dark:bg-zinc-800 dark:text-white dark:border-zinc-700">
|
<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">
|
||||||
<!-- status -->
|
<div>
|
||||||
<div v-if="iface._stats?.status === true" class="text-sm text-green-500">Connected</div>
|
<div class="detail-label">Listen</div>
|
||||||
<div v-else class="text-sm text-red-500">Disconnected</div>
|
<div class="detail-value">{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||||
|
</div>
|
||||||
<!-- stats -->
|
<div>
|
||||||
<div>• Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</div>
|
<div class="detail-label">Forward</div>
|
||||||
<div>• TX: {{ formatBytes(iface._stats?.txb ?? 0) }}</div>
|
<div class="detail-value">{{ iface.forward_ip }}:{{ iface.forward_port }}</div>
|
||||||
<div>• RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}</div>
|
</div>
|
||||||
<div v-if="iface._stats?.clients">• Clients: {{ iface._stats?.clients }}</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import DialogUtils from "../../js/DialogUtils";
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
import Utils from "../../js/Utils";
|
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 {
|
export default {
|
||||||
name: 'Interface',
|
name: 'Interface',
|
||||||
|
components: {
|
||||||
|
DropDownMenu,
|
||||||
|
IconButton,
|
||||||
|
DropDownMenuItem,
|
||||||
|
MaterialDesignIcon,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
iface: Object,
|
iface: Object,
|
||||||
},
|
},
|
||||||
@@ -175,6 +155,9 @@ export default {
|
|||||||
editInterface() {
|
editInterface() {
|
||||||
this.$emit("edit");
|
this.$emit("edit");
|
||||||
},
|
},
|
||||||
|
exportInterface() {
|
||||||
|
this.$emit("export");
|
||||||
|
},
|
||||||
deleteInterface() {
|
deleteInterface() {
|
||||||
this.$emit("delete");
|
this.$emit("delete");
|
||||||
},
|
},
|
||||||
@@ -184,6 +167,85 @@ export default {
|
|||||||
formatBytes: function(bytes) {
|
formatBytes: function(bytes) {
|
||||||
return Utils.formatBytes(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>
|
</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>
|
||||||
|
|||||||
@@ -1,81 +1,87 @@
|
|||||||
<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-6xl mx-auto w-full">
|
||||||
<!-- warning - keeping orange-500 for warning visibility in both modes -->
|
|
||||||
<div class="flex bg-orange-500 p-2 text-sm font-semibold leading-6 text-white rounded shadow">
|
<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="my-auto">
|
<div class="flex items-center gap-3">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
<MaterialDesignIcon icon-name="alert" class="w-6 h-6"/>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
<div>
|
||||||
</svg>
|
<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>
|
</div>
|
||||||
<div class="ml-2 my-auto">Reticulum MeshChat must be restarted for any interface changes to take effect.</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">
|
||||||
<button v-if="isElectron"
|
<MaterialDesignIcon icon-name="restart" class="w-4 h-4"/>
|
||||||
@click="relaunch"
|
Restart now
|
||||||
type="button"
|
|
||||||
class="ml-auto my-auto inline-flex items-center gap-x-1 rounded-md bg-white dark:bg-zinc-800 px-2 py-1 text-sm font-semibold text-black dark:text-zinc-200 shadow-sm hover:bg-gray-50 dark:hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white dark:focus-visible:outline-zinc-700">
|
|
||||||
<span>Restart Now</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex space-x-1">
|
<div class="glass-card space-y-4">
|
||||||
|
<div class="flex flex-wrap gap-3 items-center">
|
||||||
<!-- Add Interface button -->
|
<div class="flex-1">
|
||||||
<RouterLink :to="{ name: 'interfaces.add' }">
|
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Manage</div>
|
||||||
<button type="button"
|
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
|
||||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 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-700">
|
<div class="text-sm text-gray-600 dark:text-gray-300">Search, filter and export your Reticulum adapters.</div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
</div>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<div class="flex flex-wrap gap-2">
|
||||||
</svg>
|
<RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
|
||||||
<span>Add Interface</span>
|
<MaterialDesignIcon icon-name="plus" class="w-4 h-4"/>
|
||||||
</button>
|
Add Interface
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<button @click="showImportInterfacesModal" type="button" class="secondary-chip text-sm">
|
||||||
<!-- Import button -->
|
<MaterialDesignIcon icon-name="import" class="w-4 h-4"/>
|
||||||
<div class="my-auto">
|
Import
|
||||||
<button @click="showImportInterfacesModal" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 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-700">
|
</button>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
<button @click="exportInterfaces" type="button" class="secondary-chip text-sm">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
<MaterialDesignIcon icon-name="export" class="w-4 h-4"/>
|
||||||
</svg>
|
Export all
|
||||||
<span>Import</span>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3 items-center">
|
||||||
<!-- Export button -->
|
<div class="flex-1">
|
||||||
<div class="my-auto">
|
<input
|
||||||
<button @click="exportInterfaces" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 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-700">
|
v-model="searchTerm"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
type="text"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
placeholder="Search by name, type, host..."
|
||||||
</svg>
|
class="input-field"
|
||||||
<span>Export</span>
|
/>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- enabled interfaces -->
|
<div v-if="filteredInterfaces.length === 0" class="glass-card text-center py-10 text-gray-500 dark:text-gray-300">
|
||||||
<Interface
|
<MaterialDesignIcon icon-name="lan-disconnect" class="w-10 h-10 mx-auto mb-3"/>
|
||||||
v-for="iface of enabledInterfaces"
|
<div class="text-lg font-semibold">No interfaces found</div>
|
||||||
:iface="iface"
|
<div class="text-sm">Adjust your search or add a new interface.</div>
|
||||||
@enable="enableInterface(iface._name)"
|
</div>
|
||||||
@disable="disableInterface(iface._name)"
|
|
||||||
@edit="editInterface(iface._name)"
|
|
||||||
@delete="deleteInterface(iface._name)"/>
|
|
||||||
|
|
||||||
<!-- disabled interfaces -->
|
<div v-else class="grid gap-4 xl:grid-cols-2">
|
||||||
<div v-if="disabledInterfaces.length > 0" class="font-semibold dark:text-zinc-200">Disabled Interfaces</div>
|
<Interface
|
||||||
<Interface
|
v-for="iface of filteredInterfaces"
|
||||||
v-for="iface of disabledInterfaces"
|
:key="iface._name"
|
||||||
:iface="iface"
|
:iface="iface"
|
||||||
@enable="enableInterface(iface._name)"
|
@enable="enableInterface(iface._name)"
|
||||||
@disable="disableInterface(iface._name)"
|
@disable="disableInterface(iface._name)"
|
||||||
@edit="editInterface(iface._name)"
|
@edit="editInterface(iface._name)"
|
||||||
@delete="deleteInterface(iface._name)"/>
|
@export="exportInterface(iface._name)"
|
||||||
|
@delete="deleteInterface(iface._name)"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import Dialog -->
|
|
||||||
<ImportInterfacesModal ref="import-interfaces-modal" @dismissed="onImportInterfacesModalDismissed"/>
|
<ImportInterfacesModal ref="import-interfaces-modal" @dismissed="onImportInterfacesModalDismissed"/>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -84,18 +90,25 @@ import ElectronUtils from "../../js/ElectronUtils";
|
|||||||
import Interface from "./Interface.vue";
|
import Interface from "./Interface.vue";
|
||||||
import Utils from "../../js/Utils";
|
import Utils from "../../js/Utils";
|
||||||
import ImportInterfacesModal from "./ImportInterfacesModal.vue";
|
import ImportInterfacesModal from "./ImportInterfacesModal.vue";
|
||||||
|
import DownloadUtils from "../../js/DownloadUtils";
|
||||||
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'InterfacesPage',
|
name: 'InterfacesPage',
|
||||||
components: {
|
components: {
|
||||||
ImportInterfacesModal,
|
ImportInterfacesModal,
|
||||||
Interface,
|
Interface,
|
||||||
|
MaterialDesignIcon,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
interfaces: {},
|
interfaces: {},
|
||||||
interfaceStats: {},
|
interfaceStats: {},
|
||||||
reloadInterval: null,
|
reloadInterval: null,
|
||||||
|
searchTerm: "",
|
||||||
|
statusFilter: "all",
|
||||||
|
typeFilter: "all",
|
||||||
|
hasPendingInterfaceChanges: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
@@ -116,6 +129,9 @@ export default {
|
|||||||
relaunch() {
|
relaunch() {
|
||||||
ElectronUtils.relaunch();
|
ElectronUtils.relaunch();
|
||||||
},
|
},
|
||||||
|
trackInterfaceChange() {
|
||||||
|
this.hasPendingInterfaceChanges = true;
|
||||||
|
},
|
||||||
isInterfaceEnabled: function(iface) {
|
isInterfaceEnabled: function(iface) {
|
||||||
return Utils.isInterfaceEnabled(iface);
|
return Utils.isInterfaceEnabled(iface);
|
||||||
},
|
},
|
||||||
@@ -136,57 +152,13 @@ export default {
|
|||||||
// update data
|
// update data
|
||||||
const interfaces = response.data.interface_stats?.interfaces ?? [];
|
const interfaces = response.data.interface_stats?.interfaces ?? [];
|
||||||
for(const iface of interfaces){
|
for(const iface of interfaces){
|
||||||
this.interfaceStats[iface.name] = iface;
|
this.interfaceStats[iface.short_name] = iface;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
// do nothing if failed to load interfaces
|
// do nothing if failed to load interfaces
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
findInterfaceStats(interfaceName) {
|
|
||||||
const interfaceDescription = this.getInterfaceDescription(interfaceName);
|
|
||||||
return this.interfaceStats[interfaceDescription];
|
|
||||||
},
|
|
||||||
getInterfaceDescription(interfaceName) {
|
|
||||||
|
|
||||||
// the interface-stats api returns interface names like the following;
|
|
||||||
//
|
|
||||||
// "AutoInterface[Default Interface]"
|
|
||||||
// "RNodeInterface[RNode LoRa Interface Fast]"
|
|
||||||
// "TCPInterface[RNS Testnet Amsterdam/amsterdam.connect.reticulum.network:4965]"
|
|
||||||
//
|
|
||||||
// however, the interfaces api just returns;
|
|
||||||
// "Default Interface"
|
|
||||||
// "RNode LoRa Interface Fast"
|
|
||||||
// "RNS Testnet Amsterdam"
|
|
||||||
//
|
|
||||||
// so we need to map the basic interface name to the former, so we can lookup stats for the interface
|
|
||||||
const iface = this.interfaces[interfaceName];
|
|
||||||
if(iface){
|
|
||||||
switch(iface.type){
|
|
||||||
case "TCPClientInterface": {
|
|
||||||
// yes, this is meant to be passed as TCPInterface, even though the interface type includes client...
|
|
||||||
// example: "TCPInterface[RNS Testnet Amsterdam/amsterdam.connect.reticulum.network:4965]";
|
|
||||||
return `TCPInterface[${interfaceName}/${iface.target_host}:${iface.target_port}]`;
|
|
||||||
}
|
|
||||||
case "TCPServerInterface": {
|
|
||||||
// example: "TCPServerInterface[TCP Server Interface/0.0.0.0:4242]";
|
|
||||||
return `TCPServerInterface[${interfaceName}/${iface.listen_ip}:${iface.listen_port}]`;
|
|
||||||
}
|
|
||||||
case "UDPInterface": {
|
|
||||||
// example: "UDPInterface[UDP Interface/0.0.0.0:1234]";
|
|
||||||
return `UDPInterface[${interfaceName}/${iface.listen_ip}:${iface.listen_port}]`;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
// example: "RNodeInterface[RNode LoRa Interface Fast]",
|
|
||||||
return `${iface.type}[${interfaceName}]`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
|
|
||||||
},
|
|
||||||
async enableInterface(interfaceName) {
|
async enableInterface(interfaceName) {
|
||||||
|
|
||||||
// enable interface
|
// enable interface
|
||||||
@@ -194,6 +166,7 @@ export default {
|
|||||||
await window.axios.post(`/api/v1/reticulum/interfaces/enable`, {
|
await window.axios.post(`/api/v1/reticulum/interfaces/enable`, {
|
||||||
name: interfaceName,
|
name: interfaceName,
|
||||||
});
|
});
|
||||||
|
this.trackInterfaceChange();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
DialogUtils.alert("failed to enable interface");
|
DialogUtils.alert("failed to enable interface");
|
||||||
console.log(e);
|
console.log(e);
|
||||||
@@ -210,6 +183,7 @@ export default {
|
|||||||
await window.axios.post(`/api/v1/reticulum/interfaces/disable`, {
|
await window.axios.post(`/api/v1/reticulum/interfaces/disable`, {
|
||||||
name: interfaceName,
|
name: interfaceName,
|
||||||
});
|
});
|
||||||
|
this.trackInterfaceChange();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
DialogUtils.alert("failed to disable interface");
|
DialogUtils.alert("failed to disable interface");
|
||||||
console.log(e);
|
console.log(e);
|
||||||
@@ -230,7 +204,7 @@ export default {
|
|||||||
async deleteInterface(interfaceName) {
|
async deleteInterface(interfaceName) {
|
||||||
|
|
||||||
// ask user to confirm deleting conversation history
|
// ask user to confirm deleting conversation history
|
||||||
if(!confirm("Are you sure you want to delete this interface? This can not be undone!")){
|
if(!await DialogUtils.confirm("Are you sure you want to delete this interface? This can not be undone!")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +213,7 @@ export default {
|
|||||||
await window.axios.post(`/api/v1/reticulum/interfaces/delete`, {
|
await window.axios.post(`/api/v1/reticulum/interfaces/delete`, {
|
||||||
name: interfaceName,
|
name: interfaceName,
|
||||||
});
|
});
|
||||||
|
this.trackInterfaceChange();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
DialogUtils.alert("failed to delete interface");
|
DialogUtils.alert("failed to delete interface");
|
||||||
console.log(e);
|
console.log(e);
|
||||||
@@ -250,39 +225,69 @@ export default {
|
|||||||
},
|
},
|
||||||
async exportInterfaces() {
|
async exportInterfaces() {
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.get('/api/v1/reticulum/interfaces/export', {
|
|
||||||
responseType: 'blob'
|
// fetch exported interfaces
|
||||||
});
|
const response = await window.axios.post('/api/v1/reticulum/interfaces/export');
|
||||||
|
this.trackInterfaceChange();
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
||||||
const link = document.createElement('a');
|
// download file to browser
|
||||||
link.href = url;
|
DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
|
||||||
link.setAttribute('download', 'reticulum_interfaces');
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
DialogUtils.alert("Failed to export interfaces");
|
DialogUtils.alert("Failed to export interfaces");
|
||||||
console.error(e);
|
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() {
|
showImportInterfacesModal() {
|
||||||
this.$refs["import-interfaces-modal"].show();
|
this.$refs["import-interfaces-modal"].show();
|
||||||
},
|
},
|
||||||
onImportInterfacesModalDismissed() {
|
onImportInterfacesModalDismissed(imported = false) {
|
||||||
// reload interfaces as something may have been imported
|
// reload interfaces as something may have been imported
|
||||||
this.loadInterfaces();
|
this.loadInterfaces();
|
||||||
|
if(imported){
|
||||||
|
this.trackInterfaceChange();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setStatusFilter(value) {
|
||||||
|
this.statusFilter = value;
|
||||||
|
},
|
||||||
|
filterChipClass(isActive) {
|
||||||
|
return isActive
|
||||||
|
? "primary-chip text-xs"
|
||||||
|
: "secondary-chip text-xs";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isElectron() {
|
isElectron() {
|
||||||
return ElectronUtils.isElectron();
|
return ElectronUtils.isElectron();
|
||||||
},
|
},
|
||||||
|
showRestartReminder() {
|
||||||
|
return this.hasPendingInterfaceChanges;
|
||||||
|
},
|
||||||
interfacesWithStats() {
|
interfacesWithStats() {
|
||||||
const results = [];
|
const results = [];
|
||||||
for(const [interfaceName, iface] of Object.entries(this.interfaces)){
|
for(const [interfaceName, iface] of Object.entries(this.interfaces)){
|
||||||
iface._name = interfaceName;
|
iface._name = interfaceName;
|
||||||
iface._stats = this.findInterfaceStats(interfaceName);
|
iface._stats = this.interfaceStats[interfaceName];
|
||||||
results.push(iface);
|
results.push(iface);
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
@@ -293,6 +298,43 @@ export default {
|
|||||||
disabledInterfaces() {
|
disabledInterfaces() {
|
||||||
return this.interfacesWithStats.filter((iface) => !this.isInterfaceEnabled(iface));
|
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>
|
</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,12 +84,76 @@ 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
|
||||||
if(!confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
|
if(!await DialogUtils.confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,61 +64,83 @@
|
|||||||
</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">
|
||||||
<div @click="close" class="cursor-pointer">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||||
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
|
<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" />
|
||||||
<div>
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
</IconButton>
|
||||||
<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>
|
||||||
|
|
||||||
</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="[ chatItem.lxmf_message.state === 'failed' ? '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" />
|
||||||
@@ -133,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" />
|
||||||
@@ -155,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>
|
||||||
|
|
||||||
@@ -167,18 +204,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- message state -->
|
<!-- message state -->
|
||||||
<div v-if="chatItem.is_outbound" class="flex text-right" :class="[ chatItem.lxmf_message.state === 'failed' ? '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>
|
||||||
<span v-if="chatItem.lxmf_message.state === 'sent' && chatItem.lxmf_message.method === 'propagated'">to propagation node</span>
|
<span v-if="chatItem.lxmf_message.state === 'sent' && chatItem.lxmf_message.method === 'propagated'">to propagation node</span>
|
||||||
<span v-if="chatItem.lxmf_message.state === 'sending'">{{ chatItem.lxmf_message.progress.toFixed(0) }}%</span>
|
<span v-if="chatItem.lxmf_message.state === 'sending'">{{ chatItem.lxmf_message.progress.toFixed(0) }}%</span>
|
||||||
</span>
|
</span>
|
||||||
|
<a v-if="chatItem.lxmf_message.state === 'outbound' || chatItem.lxmf_message.state === 'sending' || chatItem.lxmf_message.state === 'sent'" @click="cancelSendingMessage(chatItem)" class="ml-1 cursor-pointer underline text-blue-500">cancel?</a>
|
||||||
<a v-if="chatItem.lxmf_message.state === 'failed'" @click="retrySendingMessage(chatItem)" class="ml-1 cursor-pointer underline text-blue-500">retry?</a>
|
<a v-if="chatItem.lxmf_message.state === 'failed'" @click="retrySendingMessage(chatItem)" class="ml-1 cursor-pointer underline text-blue-500">retry?</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,6 +227,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- cancelled icon -->
|
||||||
|
<div v-else-if="chatItem.lxmf_message.state === 'cancelled'" class="my-auto">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||||
|
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-1.72 6.97a.75.75 0 1 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- failed icon -->
|
<!-- failed icon -->
|
||||||
<div v-else-if="chatItem.lxmf_message.state === 'failed'" class="my-auto">
|
<div v-else-if="chatItem.lxmf_message.state === 'failed'" class="my-auto">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
||||||
@@ -207,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>
|
||||||
@@ -229,126 +279,89 @@
|
|||||||
</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>
|
||||||
|
|
||||||
<!-- text input -->
|
<!-- text input -->
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="message-input"
|
||||||
id="message-input"
|
id="message-input"
|
||||||
:readonly="isSendingMessage"
|
:readonly="isSendingMessage"
|
||||||
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"
|
||||||
@@ -357,7 +370,6 @@
|
|||||||
:can-send-message="canSendMessage"
|
:can-send-message="canSendMessage"
|
||||||
:delivery-method="newMessageDeliveryMethod"/>
|
:delivery-method="newMessageDeliveryMethod"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -371,16 +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">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-zinc-100 mb-1">No Active Chat</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-zinc-400">Select a peer from the sidebar or enter an address below</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- compose message input -->
|
||||||
|
<div class="w-full">
|
||||||
|
<input
|
||||||
|
ref="compose-input"
|
||||||
|
id="compose-input"
|
||||||
|
:readonly="isSendingMessage"
|
||||||
|
v-model="composeAddress"
|
||||||
|
@keydown.enter.exact.prevent="onComposeEnterPressed"
|
||||||
|
type="text"
|
||||||
|
class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
|
||||||
|
placeholder="Enter LXMF address..."/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold dark:text-white">No Active Chat</div>
|
|
||||||
<div class='dark:text-zinc-300'>Select a Peer to start chatting!</div>
|
|
||||||
</div>
|
</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>
|
||||||
@@ -395,10 +444,13 @@ import SendMessageButton from "./SendMessageButton.vue";
|
|||||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
|
import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
|
||||||
import AddImageButton from "./AddImageButton.vue";
|
import AddImageButton from "./AddImageButton.vue";
|
||||||
|
import IconButton from "../IconButton.vue";
|
||||||
|
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ConversationViewer',
|
name: 'ConversationViewer',
|
||||||
components: {
|
components: {
|
||||||
|
IconButton,
|
||||||
AddImageButton,
|
AddImageButton,
|
||||||
ConversationDropDownMenu,
|
ConversationDropDownMenu,
|
||||||
MaterialDesignIcon,
|
MaterialDesignIcon,
|
||||||
@@ -431,6 +483,7 @@ export default {
|
|||||||
newMessageFiles: [],
|
newMessageFiles: [],
|
||||||
isSendingMessage: false,
|
isSendingMessage: false,
|
||||||
autoScrollOnNewMessage: true,
|
autoScrollOnNewMessage: true,
|
||||||
|
composeAddress: "",
|
||||||
|
|
||||||
isRecordingAudioAttachment: false,
|
isRecordingAudioAttachment: false,
|
||||||
audioAttachmentMicrophoneRecorder: null,
|
audioAttachmentMicrophoneRecorder: null,
|
||||||
@@ -439,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
|
||||||
@@ -457,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");
|
||||||
},
|
},
|
||||||
@@ -596,6 +686,40 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openLXMFAddress() {
|
||||||
|
GlobalEmitter.emit("compose-new-message");
|
||||||
|
},
|
||||||
|
onComposeNewMessageEvent(destinationHash) {
|
||||||
|
if(!this.selectedPeer && !destinationHash){
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const composeInput = document.getElementById("compose-input");
|
||||||
|
if(composeInput){
|
||||||
|
composeInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onComposeSubmit() {
|
||||||
|
if(!this.composeAddress || this.composeAddress.trim() === ""){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let destinationHash = this.composeAddress.trim();
|
||||||
|
this.composeAddress = "";
|
||||||
|
await this.handleComposeAddress(destinationHash);
|
||||||
|
},
|
||||||
|
onComposeEnterPressed() {
|
||||||
|
this.onComposeSubmit();
|
||||||
|
},
|
||||||
|
async handleComposeAddress(destinationHash) {
|
||||||
|
if(destinationHash.startsWith("lxmf@")){
|
||||||
|
destinationHash = destinationHash.replace("lxmf@", "");
|
||||||
|
}
|
||||||
|
if(destinationHash.length !== 32){
|
||||||
|
DialogUtils.alert("Invalid Address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GlobalEmitter.emit("compose-new-message", destinationHash);
|
||||||
|
},
|
||||||
onLxmfMessageReceived(lxmfMessage) {
|
onLxmfMessageReceived(lxmfMessage) {
|
||||||
|
|
||||||
// add inbound message to ui
|
// add inbound message to ui
|
||||||
@@ -844,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) {
|
||||||
|
|
||||||
@@ -979,7 +1097,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
// ask user to confirm deleting message
|
// ask user to confirm deleting message
|
||||||
if(shouldConfirm && !confirm("Are you sure you want to delete this message? This can not be undone!")){
|
if(shouldConfirm && !await DialogUtils.confirm("Are you sure you want to delete this message? This can not be undone!")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1038,7 +1156,11 @@ export default {
|
|||||||
if(this.newMessageImage){
|
if(this.newMessageImage){
|
||||||
imageTotalSize = this.newMessageImage.size;
|
imageTotalSize = this.newMessageImage.size;
|
||||||
fields["image"] = {
|
fields["image"] = {
|
||||||
// Reticulum sends image type as "jpg" or "png" and not "image/jpg" or "image/png"
|
// Reticulum sends image type as "jpg", "png", "webp" etc and not "image/jpg" or "image/png"
|
||||||
|
// From memory, Sideband would not display images if the image type has the "image/" prefix
|
||||||
|
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/docs/example_plugins/view.py#L78
|
||||||
|
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/main.py#L1900
|
||||||
|
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/ui/messages.py#L783
|
||||||
"image_type": this.newMessageImage.type.replace("image/", ""),
|
"image_type": this.newMessageImage.type.replace("image/", ""),
|
||||||
"image_bytes": Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()),
|
"image_bytes": Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()),
|
||||||
};
|
};
|
||||||
@@ -1060,7 +1182,7 @@ export default {
|
|||||||
|
|
||||||
// ask user if they still want to send message if it may be rejected by sender
|
// ask user if they still want to send message if it may be rejected by sender
|
||||||
if(totalMessageSize > 1000 * 900){ // actual limit in LXMF Router is 1mb
|
if(totalMessageSize > 1000 * 900){ // actual limit in LXMF Router is 1mb
|
||||||
if(!confirm(`Your message exceeds 900KB (It's ${this.formatBytes(totalMessageSize)}). It may be rejected by the recipient unless they have increased their delivery limit. Do you want to try sending anyway?`)){
|
if(!await DialogUtils.confirm(`Your message exceeds 900KB (It's ${this.formatBytes(totalMessageSize)}). It may be rejected by the recipient unless they have increased their delivery limit. Do you want to try sending anyway?`)){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1106,6 +1228,38 @@ export default {
|
|||||||
this.isSendingMessage = false;
|
this.isSendingMessage = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
async cancelSendingMessage(chatItem) {
|
||||||
|
|
||||||
|
// get lxmf message hash else do nothing
|
||||||
|
const lxmfMessageHash = chatItem.lxmf_message.hash;
|
||||||
|
if(!lxmfMessageHash){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
// cancel sending lxmf message
|
||||||
|
const response = await window.axios.post(`/api/v1/lxmf-messages/${lxmfMessageHash}/cancel`);
|
||||||
|
|
||||||
|
// get lxmf message from response
|
||||||
|
const lxmfMessage = response.data.lxmf_message;
|
||||||
|
if(!lxmfMessage){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update lxmf message in ui
|
||||||
|
this.onLxmfMessageUpdated(lxmfMessage);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
|
||||||
|
// show error
|
||||||
|
const message = e.response?.data?.message ?? "failed to cancel message";
|
||||||
|
DialogUtils.alert(message);
|
||||||
|
console.log(e);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
async retrySendingMessage(chatItem) {
|
async retrySendingMessage(chatItem) {
|
||||||
|
|
||||||
@@ -1151,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);
|
||||||
@@ -1159,10 +1330,10 @@ export default {
|
|||||||
clearFileInput: function() {
|
clearFileInput: function() {
|
||||||
this.$refs["file-input"].value = null;
|
this.$refs["file-input"].value = null;
|
||||||
},
|
},
|
||||||
removeImageAttachment: function() {
|
async removeImageAttachment() {
|
||||||
|
|
||||||
// ask user to confirm removing image attachment
|
// ask user to confirm removing image attachment
|
||||||
if(!confirm("Are you sure you want to remove this image attachment?")){
|
if(!await DialogUtils.confirm("Are you sure you want to remove this image attachment?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1194,7 +1365,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ask user to confirm recording new audio attachment, if an existing audio attachment exists
|
// ask user to confirm recording new audio attachment, if an existing audio attachment exists
|
||||||
if(this.newMessageAudio && !confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
|
if(this.newMessageAudio && !await DialogUtils.confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,10 +1507,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
removeAudioAttachment: function() {
|
async removeAudioAttachment() {
|
||||||
|
|
||||||
// ask user to confirm removing audio attachment
|
// ask user to confirm removing audio attachment
|
||||||
if(!confirm("Are you sure you want to remove this audio attachment?")){
|
if(!await DialogUtils.confirm("Are you sure you want to remove this audio attachment?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1353,7 +1524,22 @@ export default {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
addNewLine: function() {
|
addNewLine: function() {
|
||||||
this.newMessageText += "\n";
|
|
||||||
|
// get cursor position for message input
|
||||||
|
const input = this.$refs["message-input"];
|
||||||
|
const cursorPosition = input.selectionStart;
|
||||||
|
|
||||||
|
// insert a newline character after the cursor position
|
||||||
|
const text = this.newMessageText;
|
||||||
|
this.newMessageText = text.slice(0, cursorPosition) + '\n' + text.slice(cursorPosition);
|
||||||
|
|
||||||
|
// move cursor to the position after the added newline
|
||||||
|
const newCursorPosition = cursorPosition + 1;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
input.selectionStart = newCursorPosition;
|
||||||
|
input.selectionEnd = newCursorPosition;
|
||||||
|
});
|
||||||
|
|
||||||
},
|
},
|
||||||
onEnterPressed: function() {
|
onEnterPressed: function() {
|
||||||
|
|
||||||
@@ -1395,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: {
|
||||||
@@ -1549,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
|
||||||
@@ -15,7 +23,7 @@
|
|||||||
:my-lxmf-address-hash="config?.lxmf_address_hash"
|
:my-lxmf-address-hash="config?.lxmf_address_hash"
|
||||||
:selected-peer="selectedPeer"
|
:selected-peer="selectedPeer"
|
||||||
:conversations="conversations"
|
:conversations="conversations"
|
||||||
@close="selectedPeer = null"
|
@close="onCloseConversationViewer"
|
||||||
@reload-conversations="getConversations"/>
|
@reload-conversations="getConversations"/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -38,10 +46,14 @@ export default {
|
|||||||
ConversationViewer,
|
ConversationViewer,
|
||||||
MessagesSidebar,
|
MessagesSidebar,
|
||||||
},
|
},
|
||||||
|
props: {
|
||||||
|
destinationHash: String,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
||||||
reloadInterval: null,
|
reloadInterval: null,
|
||||||
|
conversationRefreshTimeout: null,
|
||||||
|
|
||||||
config: null,
|
config: null,
|
||||||
peers: {},
|
peers: {},
|
||||||
@@ -50,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);
|
||||||
@@ -76,32 +95,44 @@ export default {
|
|||||||
this.getConversations();
|
this.getConversations();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
|
// compose message if a destination hash was provided on page load
|
||||||
|
if(this.destinationHash){
|
||||||
|
this.onComposeNewMessage(this.destinationHash);
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// attempt to find existing peer so we can show their name
|
if(destinationHash.startsWith("lxmf@")){
|
||||||
|
destinationHash = destinationHash.replace("lxmf@", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.getLxmfDeliveryAnnounce(destinationHash);
|
||||||
|
|
||||||
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,
|
||||||
@@ -160,20 +191,79 @@ export default {
|
|||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async getLxmfDeliveryAnnounce(destinationHash) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// fetch announce for destination hash
|
||||||
|
const response = await window.axios.get(`/api/v1/announces`, {
|
||||||
|
params: {
|
||||||
|
destination_hash: destinationHash,
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// update ui
|
||||||
|
const lxmfDeliveryAnnounces = response.data.announces;
|
||||||
|
for(const lxmfDeliveryAnnounce of lxmfDeliveryAnnounces){
|
||||||
|
this.updatePeerFromAnnounce(lxmfDeliveryAnnounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
// do nothing if failed to load announce
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
async getConversations() {
|
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;
|
||||||
},
|
},
|
||||||
onPeerClick: function(peer) {
|
onPeerClick: function(peer) {
|
||||||
|
|
||||||
|
// update selected peer
|
||||||
this.selectedPeer = peer;
|
this.selectedPeer = peer;
|
||||||
|
|
||||||
|
// update current route
|
||||||
|
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
|
||||||
|
const routeOptions = {
|
||||||
|
name: routeName,
|
||||||
|
params: {
|
||||||
|
destinationHash: peer.destination_hash,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if(!this.isPopoutMode && this.$route?.query){
|
||||||
|
routeOptions.query = { ...this.$route.query };
|
||||||
|
}
|
||||||
|
this.$router.replace(routeOptions);
|
||||||
|
|
||||||
},
|
},
|
||||||
onConversationClick: function(conversation) {
|
onConversationClick: function(conversation) {
|
||||||
|
|
||||||
@@ -184,6 +274,63 @@ export default {
|
|||||||
this.$refs["conversation-viewer"].markConversationAsRead(conversation);
|
this.$refs["conversation-viewer"].markConversationAsRead(conversation);
|
||||||
|
|
||||||
},
|
},
|
||||||
|
onCloseConversationViewer: function() {
|
||||||
|
|
||||||
|
// clear selected peer
|
||||||
|
this.selectedPeer = null;
|
||||||
|
|
||||||
|
if(this.isPopoutMode){
|
||||||
|
window.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update current route
|
||||||
|
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
|
||||||
|
const routeOptions = { name: routeName };
|
||||||
|
if(!this.isPopoutMode && this.$route?.query){
|
||||||
|
routeOptions.query = { ...this.$route.query };
|
||||||
|
}
|
||||||
|
this.$router.replace(routeOptions);
|
||||||
|
|
||||||
|
},
|
||||||
|
requestConversationsRefresh() {
|
||||||
|
if(this.conversationRefreshTimeout){
|
||||||
|
clearTimeout(this.conversationRefreshTimeout);
|
||||||
|
}
|
||||||
|
this.conversationRefreshTimeout = setTimeout(() => {
|
||||||
|
this.getConversations();
|
||||||
|
}, 250);
|
||||||
|
},
|
||||||
|
onConversationSearchChanged(term) {
|
||||||
|
this.conversationSearchTerm = term;
|
||||||
|
this.requestConversationsRefresh();
|
||||||
|
},
|
||||||
|
onConversationFilterChanged(filterKey) {
|
||||||
|
if(filterKey === 'unread'){
|
||||||
|
this.filterUnreadOnly = !this.filterUnreadOnly;
|
||||||
|
} else if(filterKey === 'failed'){
|
||||||
|
this.filterFailedOnly = !this.filterFailedOnly;
|
||||||
|
} else if(filterKey === 'attachments'){
|
||||||
|
this.filterHasAttachmentsOnly = !this.filterHasAttachmentsOnly;
|
||||||
|
}
|
||||||
|
this.requestConversationsRefresh();
|
||||||
|
},
|
||||||
|
getHashPopoutValue() {
|
||||||
|
const hash = window.location.hash || "";
|
||||||
|
const match = hash.match(/popout=([^&]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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: {
|
||||||
conversations() {
|
conversations() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="inline-flex rounded-md shadow-sm">
|
<div class="relative inline-flex rounded-xl shadow-sm">
|
||||||
<!-- send button -->
|
<!-- send button -->
|
||||||
<button @click="send" :disabled="!canSendMessage" type="button" class="my-auto inline-flex items-center rounded-l-md px-2.5 py-1.5 text-sm font-semibold text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" :class="[ canSendMessage ? 'bg-blue-500 dark:bg-blue-600 hover:bg-blue-400 dark:hover:bg-blue-500 focus-visible:outline-blue-500 dark:focus-visible:outline-blue-600' : 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-500 cursor-not-allowed']">
|
<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']">
|
||||||
<span v-if="isSendingMessage">Sending...</span>
|
<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">
|
||||||
<span v-else class="space-x-1">
|
<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" />
|
||||||
<span>Send</span>
|
</svg>
|
||||||
<span v-if="deliveryMethod === 'direct'">(Direct Link)</span>
|
<span v-if="isSendingMessage" class="flex items-center gap-2">
|
||||||
<span v-if="deliveryMethod === 'opportunistic'">(Opportunistic)</span>
|
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<span v-if="deliveryMethod === 'propagated'">(Propagated)</span>
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<!-- dropdown button -->
|
<!-- dropdown button -->
|
||||||
<button @click="showMenu" :disabled="!canSendMessage" type="button" class="my-auto border-l relative inline-flex items-center rounded-r-md px-2 py-1.5 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" :class="[ canSendMessage ? 'bg-blue-500 dark:bg-blue-600 hover:bg-blue-400 dark:hover:bg-blue-500 focus-visible:outline-blue-500 dark:focus-visible:outline-blue-600 border-blue-600 dark:border-blue-700' : 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-500 border-gray-500 dark:border-zinc-600 cursor-not-allowed']">
|
<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-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" data-slot="icon">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -25,12 +34,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 right-0 ml-0 z-10 mb-10 rounded-md bg-white dark:bg-zinc-800 shadow-lg ring-1 ring-black dark:ring-zinc-700 ring-opacity-5 focus:outline-none">
|
<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">
|
<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-700 whitespace-nowrap border-b border-gray-200 dark:border-zinc-700">Send Automatically</button>
|
<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-700 whitespace-nowrap">Send over Direct Link</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-700 whitespace-nowrap">Send Opportunistically</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-700 whitespace-nowrap">Send to Propagation Node</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>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -165,6 +265,60 @@ export default {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// handle double click on a node
|
||||||
|
this.network.on("doubleClick", (params) => {
|
||||||
|
|
||||||
|
// get clicked node id
|
||||||
|
const clickedNodeId = params.nodes[0];
|
||||||
|
if(!clickedNodeId){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// find node by id
|
||||||
|
const node = this.network.body.nodes[clickedNodeId];
|
||||||
|
if(!node){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle double click on an announce node
|
||||||
|
if(node.options.group === "announce"){
|
||||||
|
|
||||||
|
// get announce
|
||||||
|
const announce = node.options._announce;
|
||||||
|
if(!announce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle double click on lxmf.delivery node
|
||||||
|
if(announce.aspect === "lxmf.delivery"){
|
||||||
|
|
||||||
|
// go to messages page for this destination hash
|
||||||
|
this.$router.push({
|
||||||
|
name: "messages",
|
||||||
|
params: {
|
||||||
|
destinationHash: announce.destination_hash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle double click on nomadnetwork.node node
|
||||||
|
if(announce.aspect === "nomadnetwork.node"){
|
||||||
|
|
||||||
|
// go to nomadnetwork page for this destination hash
|
||||||
|
this.$router.push({
|
||||||
|
name: "nomadnetwork",
|
||||||
|
params: {
|
||||||
|
destinationHash: announce.destination_hash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
// update network
|
// update network
|
||||||
await this.update();
|
await this.update();
|
||||||
|
|
||||||
@@ -202,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",
|
||||||
@@ -221,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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,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",
|
||||||
@@ -268,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
|
||||||
@@ -281,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",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,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 = [
|
||||||
@@ -358,6 +529,9 @@ export default {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attach announce to this node
|
||||||
|
node._announce = announce;
|
||||||
|
|
||||||
// add node
|
// add node
|
||||||
nodes.push(node);
|
nodes.push(node);
|
||||||
|
|
||||||
@@ -366,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -447,3 +622,4 @@ export default {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,78 @@
|
|||||||
|
|
||||||
<!-- nomadnetwork sidebar -->
|
<!-- nomadnetwork sidebar -->
|
||||||
<NomadNetworkSidebar
|
<NomadNetworkSidebar
|
||||||
|
v-if="!isPopoutMode"
|
||||||
:nodes="nodes"
|
:nodes="nodes"
|
||||||
|
:favourites="favourites"
|
||||||
:selected-destination-hash="selectedNode?.destination_hash"
|
:selected-destination-hash="selectedNode?.destination_hash"
|
||||||
@node-click="onNodeClick"/>
|
@node-click="onNodeClick"
|
||||||
|
@rename-favourite="onRenameFavourite"
|
||||||
|
@remove-favourite="onRemoveFavourite"/>
|
||||||
|
|
||||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||||
<!-- node -->
|
<!-- node -->
|
||||||
<div v-if="selectedNode" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
|
<div v-if="selectedNode" class="flex flex-col h-full 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">
|
||||||
|
|
||||||
|
<!-- favourite button -->
|
||||||
|
<div class="my-auto mr-2">
|
||||||
|
<div v-if="isFavourite(selectedNode.destination_hash)" @click="removeFavourite(selectedNode)" class="cursor-pointer">
|
||||||
|
<div class="flex text-yellow-500 dark:text-yellow-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
||||||
|
<div>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||||
|
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else @click="addFavourite(selectedNode)" class="cursor-pointer">
|
||||||
|
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
||||||
|
<div>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- node info -->
|
<!-- node info -->
|
||||||
<div class="my-auto dark:text-gray-100">
|
<div class="my-auto dark:text-gray-100 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>
|
||||||
|
|
||||||
|
<!-- identify button -->
|
||||||
|
<div class="my-auto ml-auto mr-2">
|
||||||
|
<div @click="identify(selectedNode.destination_hash)" class="cursor-pointer">
|
||||||
|
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
||||||
|
<div>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.864 4.243A7.5 7.5 0 0 1 19.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 0 0 4.5 10.5a7.464 7.464 0 0 1-1.15 3.993m1.989 3.559A11.209 11.209 0 0 0 8.25 10.5a3.75 3.75 0 1 1 7.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 0 1-3.6 9.75m6.633-4.596a18.666 18.666 0 0 1-2.485 5.33" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<!-- close button -->
|
<!-- close button -->
|
||||||
<div class="my-auto ml-auto mr-2">
|
<div class="my-auto mr-2">
|
||||||
<div @click="selectedNode = null" class="cursor-pointer">
|
<div @click="onCloseNodeViewer" class="cursor-pointer">
|
||||||
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
||||||
<div>
|
<div>
|
||||||
<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">
|
||||||
@@ -33,7 +87,7 @@
|
|||||||
|
|
||||||
<!-- browser navigation -->
|
<!-- browser navigation -->
|
||||||
<div class="flex w-full border-gray-300 dark:border-zinc-800 border-b p-2">
|
<div class="flex w-full border-gray-300 dark:border-zinc-800 border-b p-2">
|
||||||
<button @click="loadNodePage(selectedNode.destination_hash, '/page/index.mu')" type="button" class="my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer">
|
<button @click="loadNodePage(selectedNode.destination_hash, defaultNodePagePath)" type="button" class="my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer">
|
||||||
<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="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -43,6 +97,11 @@
|
|||||||
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="toggleNodePageSource" type="button" title="Toggle Source Code" class="ml-1 my-auto text-gray-500 dark:text-gray-300 rounded p-1 cursor-pointer" :class="[ isShowingNodePageSource ? 'bg-green-500 hover:bg-green-600 text-white' : 'bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' ]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button @click="loadPreviousNodePage" type="button" :disabled="nodePagePathHistory.length === 0" :class="[ nodePagePathHistory.length > 0 ? 'text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' : 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-zinc-900']" class="ml-1 my-auto rounded p-1 cursor-pointer">
|
<button @click="loadPreviousNodePage" type="button" :disabled="nodePagePathHistory.length === 0" :class="[ nodePagePathHistory.length > 0 ? 'text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' : 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-zinc-900']" class="ml-1 my-auto rounded p-1 cursor-pointer">
|
||||||
<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="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
|
||||||
@@ -59,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">
|
||||||
@@ -67,9 +126,12 @@
|
|||||||
<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="nodePageContent" class="h-full text-wrap"></pre>
|
<pre v-else v-html="renderedNodePageContent()" class="h-full break-words whitespace-pre-wrap"></pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- file download bottom bar -->
|
<!-- file download bottom bar -->
|
||||||
@@ -80,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>
|
||||||
|
|
||||||
@@ -93,6 +163,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">No Active Node</div>
|
<div class="font-semibold">No Active Node</div>
|
||||||
<div>Select a Node to start browsing!</div>
|
<div>Select a Node to start browsing!</div>
|
||||||
|
<div class="mx-auto mt-2">
|
||||||
|
<button @click.stop="openUrl" type="button"
|
||||||
|
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500
|
||||||
|
dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||||
|
Open a Nomadnet URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,25 +200,35 @@ pre a:hover {
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import MicronParser from "../../js/MicronParser";
|
import MicronParser from "micron-parser";
|
||||||
import DialogUtils from "../../js/DialogUtils";
|
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',
|
||||||
components: {
|
components: {
|
||||||
NomadNetworkSidebar,
|
NomadNetworkSidebar,
|
||||||
},
|
},
|
||||||
|
props: {
|
||||||
|
destinationHash: String,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
||||||
|
reloadInterval: null,
|
||||||
|
|
||||||
nodes: {},
|
nodes: {},
|
||||||
selectedNode: null,
|
selectedNode: null,
|
||||||
selectedNodePath: null,
|
selectedNodePath: null,
|
||||||
|
|
||||||
|
favourites: [],
|
||||||
|
|
||||||
isLoadingNodePage: false,
|
isLoadingNodePage: false,
|
||||||
|
isShowingNodePageSource: false,
|
||||||
|
defaultNodePagePath: "/page/index.mu",
|
||||||
nodePageRequestSequence: 0,
|
nodePageRequestSequence: 0,
|
||||||
nodePagePath: null,
|
nodePagePath: null,
|
||||||
nodePagePathUrlInput: null,
|
nodePagePathUrlInput: null,
|
||||||
@@ -149,11 +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: {},
|
||||||
@@ -161,23 +253,79 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
|
|
||||||
|
clearInterval(this.reloadInterval);
|
||||||
|
|
||||||
// stop listening for websocket messages
|
// stop listening for websocket messages
|
||||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||||
|
|
||||||
|
// stop listening for element clicks
|
||||||
|
window.document.removeEventListener('click', this.onElementClick);
|
||||||
|
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
||||||
// listen for websocket messages
|
// listen for websocket messages
|
||||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||||
|
|
||||||
// fixme: this is called by the micron-parser.js
|
// listen for element clicks
|
||||||
window.onNodePageUrlClick = (url, options = null) => {
|
window.document.addEventListener('click', this.onElementClick);
|
||||||
this.onNodePageUrlClick(url, options);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// load nomadnetwork node if a destination hash was provided on page load
|
||||||
|
if(this.destinationHash){
|
||||||
|
(async () => {
|
||||||
|
// fetch updated announce as we are probably loading node page before we loaded the announces list
|
||||||
|
await this.getNomadnetworkNodeAnnounce(this.destinationHash);
|
||||||
|
await this.onNodePageUrlClick(`${this.destinationHash}:${this.defaultNodePagePath}`);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.getFavourites();
|
||||||
this.getNomadnetworkNodeAnnounces();
|
this.getNomadnetworkNodeAnnounces();
|
||||||
|
|
||||||
|
// update info every few seconds
|
||||||
|
this.reloadInterval = setInterval(() => {
|
||||||
|
this.getFavourites();
|
||||||
|
}, 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) {
|
||||||
|
|
||||||
|
// find the closest ancestor (or the clicked element itself) with data-action="openNode"
|
||||||
|
const element = event.target.closest('[data-action="openNode"]');
|
||||||
|
if(!element){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the destination and fields
|
||||||
|
const destination = element.getAttribute("data-destination");
|
||||||
|
const fields = element.getAttribute("data-fields");
|
||||||
|
|
||||||
|
// navigate to destination
|
||||||
|
this.onNodePageUrlClick(destination, fields);
|
||||||
|
|
||||||
|
},
|
||||||
async onWebsocketMessage(message) {
|
async onWebsocketMessage(message) {
|
||||||
const json = JSON.parse(message.data);
|
const json = JSON.parse(message.data);
|
||||||
switch(json.type){
|
switch(json.type){
|
||||||
@@ -192,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);
|
||||||
@@ -205,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,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);
|
||||||
@@ -241,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,11 +426,79 @@ 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) {
|
||||||
DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`);
|
DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`);
|
||||||
},
|
},
|
||||||
|
async getFavourites() {
|
||||||
|
try {
|
||||||
|
const response = await window.axios.get("/api/v1/favourites", {
|
||||||
|
params: {
|
||||||
|
aspect: "nomadnetwork.node",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.favourites = response.data.favourites;
|
||||||
|
} catch(e) {
|
||||||
|
// do nothing if failed to load favourites
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isFavourite(destinationHash) {
|
||||||
|
return this.favourites.find((favourite) => {
|
||||||
|
return favourite.destination_hash === destinationHash;
|
||||||
|
}) != null;
|
||||||
|
},
|
||||||
|
async addFavourite(node) {
|
||||||
|
|
||||||
|
// add to favourites
|
||||||
|
try {
|
||||||
|
await window.axios.post("/api/v1/favourites/add", {
|
||||||
|
destination_hash: node.destination_hash,
|
||||||
|
display_name: node.display_name,
|
||||||
|
aspect: "nomadnetwork.node",
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update favourites
|
||||||
|
this.getFavourites();
|
||||||
|
|
||||||
|
},
|
||||||
|
async removeFavourite(node) {
|
||||||
|
|
||||||
|
// remove from favourites
|
||||||
|
try {
|
||||||
|
await window.axios.delete(`/api/v1/favourites/${node.destination_hash}`);
|
||||||
|
} catch(e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update favourites
|
||||||
|
this.getFavourites();
|
||||||
|
|
||||||
|
},
|
||||||
async getNomadnetworkNodeAnnounces() {
|
async getNomadnetworkNodeAnnounces() {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -272,6 +506,7 @@ export default {
|
|||||||
const response = await window.axios.get(`/api/v1/announces`, {
|
const response = await window.axios.get(`/api/v1/announces`, {
|
||||||
params: {
|
params: {
|
||||||
aspect: "nomadnetwork.node",
|
aspect: "nomadnetwork.node",
|
||||||
|
limit: 500, // limit ui to showing 500 latest announces
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -286,11 +521,58 @@ export default {
|
|||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async getNomadnetworkNodeAnnounce(destinationHash) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// fetch announces for "nomadnetwork.node" aspect
|
||||||
|
const response = await window.axios.get(`/api/v1/announces`, {
|
||||||
|
params: {
|
||||||
|
destination_hash: destinationHash,
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// update ui
|
||||||
|
const nodeAnnounces = response.data.announces;
|
||||||
|
for(const nodeAnnounce of nodeAnnounces){
|
||||||
|
this.updateNodeFromAnnounce(nodeAnnounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
// do nothing if failed to load announce
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
updateNodeFromAnnounce: function(announce) {
|
updateNodeFromAnnounce: function(announce) {
|
||||||
this.nodes[announce.destination_hash] = announce;
|
this.nodes[announce.destination_hash] = announce;
|
||||||
},
|
},
|
||||||
|
async openUrl() {
|
||||||
|
|
||||||
|
// ask for url
|
||||||
|
const url = await DialogUtils.prompt("Enter a Nomadnet URL");
|
||||||
|
if(!url){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// navigate to the url
|
||||||
|
await this.onNodePageUrlClick(url);
|
||||||
|
|
||||||
|
},
|
||||||
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
|
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
|
||||||
|
|
||||||
|
// update current route
|
||||||
|
const routeName = this.isPopoutMode ? "nomadnetwork-popout" : "nomadnetwork";
|
||||||
|
const routeOptions = {
|
||||||
|
name: routeName,
|
||||||
|
params: {
|
||||||
|
destinationHash: destinationHash,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if(!this.isPopoutMode && this.$route?.query){
|
||||||
|
routeOptions.query = { ...this.$route.query };
|
||||||
|
}
|
||||||
|
this.$router.replace(routeOptions);
|
||||||
|
|
||||||
// get new sequence for this page load
|
// get new sequence for this page load
|
||||||
const seq = ++this.nodePageRequestSequence;
|
const seq = ++this.nodePageRequestSequence;
|
||||||
|
|
||||||
@@ -324,6 +606,7 @@ export default {
|
|||||||
// if page is cache, we can just return it now
|
// if page is cache, we can just return it now
|
||||||
if(cachedNodePageContent != null){
|
if(cachedNodePageContent != null){
|
||||||
this.nodePageContent = cachedNodePageContent;
|
this.nodePageContent = cachedNodePageContent;
|
||||||
|
this.renderPageContent(pagePath, cachedNodePageContent);
|
||||||
this.isLoadingNodePage = false;
|
this.isLoadingNodePage = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -332,31 +615,21 @@ export default {
|
|||||||
|
|
||||||
this.downloadNomadNetPage(destinationHash, pagePath, fieldData, (pageContent) => {
|
this.downloadNomadNetPage(destinationHash, pagePath, fieldData, (pageContent) => {
|
||||||
|
|
||||||
const muParser = new MicronParser();
|
|
||||||
|
|
||||||
// do nothing if callback is for a previous request
|
// do nothing if callback is for a previous request
|
||||||
if(seq !== this.nodePageRequestSequence){
|
if(seq !== this.nodePageRequestSequence){
|
||||||
console.log("ignoring page content callback for previous page request")
|
console.log("ignoring page content callback for previous page request")
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if page url ends with .mu but remove page data first
|
// update page content
|
||||||
// address:/page/index.mu`Data=123
|
this.nodePageContent = pageContent;
|
||||||
const [ pagePathWithoutData, pageData ] = pagePath.split("`");
|
|
||||||
|
|
||||||
// convert micron to html if page ends with .mu extension
|
|
||||||
// otherwise, we will just serve the content as is
|
|
||||||
if(pagePathWithoutData.endsWith(".mu")){
|
|
||||||
this.nodePageContent = muParser.convertMicronToHtml(pageContent);
|
|
||||||
} else {
|
|
||||||
this.nodePageContent = pageContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update cache
|
// update cache
|
||||||
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
|
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
|
||||||
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
|
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
|
||||||
|
|
||||||
// update page content
|
// update page content
|
||||||
|
this.renderPageContent(pagePath, pageContent);
|
||||||
this.isLoadingNodePage = false;
|
this.isLoadingNodePage = false;
|
||||||
|
|
||||||
// update node path
|
// update node path
|
||||||
@@ -390,6 +663,35 @@ export default {
|
|||||||
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
renderPageContent(path, content) {
|
||||||
|
|
||||||
|
// render page content if we aren't viewing source
|
||||||
|
if(!this.isShowingNodePageSource){
|
||||||
|
|
||||||
|
// check if page url ends with .mu but remove page data first
|
||||||
|
// address:/page/index.mu`Data=123
|
||||||
|
const [ pagePathWithoutData ] = path.split("`");
|
||||||
|
|
||||||
|
// convert micron to html if page ends with .mu extension
|
||||||
|
if(pagePathWithoutData.endsWith(".mu")){
|
||||||
|
const muParser = new MicronParser();
|
||||||
|
return muParser.convertMicronToHtml(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise, we will just serve the raw content, making sure to prevent injecting html
|
||||||
|
return content
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
},
|
||||||
|
toggleNodePageSource() {
|
||||||
|
this.isShowingNodePageSource = !this.isShowingNodePageSource;
|
||||||
|
},
|
||||||
async reloadNodePage() {
|
async reloadNodePage() {
|
||||||
|
|
||||||
// reload current node page without adding to history and without using cache
|
// reload current node page without adding to history and without using cache
|
||||||
@@ -416,9 +718,9 @@ export default {
|
|||||||
// remove leading ":"
|
// remove leading ":"
|
||||||
var path = url.substring(1);
|
var path = url.substring(1);
|
||||||
|
|
||||||
// if page path is empty we should load "/page/index.mu"
|
// if page path is empty we should load default page path
|
||||||
if(path === ""){
|
if(path === ""){
|
||||||
path = "/page/index.mu";
|
path = this.defaultNodePagePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -448,7 +750,7 @@ export default {
|
|||||||
if(url.length === 32){
|
if(url.length === 32){
|
||||||
return {
|
return {
|
||||||
destination_hash: url,
|
destination_hash: url,
|
||||||
path: "/page/index.mu",
|
path: this.defaultNodePagePath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,8 +822,13 @@ 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){
|
||||||
await this.$router.push({ name: "messages" });
|
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
|
||||||
GlobalEmitter.emit("compose-new-message", destinationHash);
|
await this.$router.push({
|
||||||
|
name: routeName,
|
||||||
|
params: {
|
||||||
|
destinationHash: destinationHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -546,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;
|
||||||
@@ -619,9 +972,70 @@ 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
|
||||||
this.selectedNode = node;
|
this.selectedNode = node;
|
||||||
this.loadNodePage(node.destination_hash, "/page/index.mu");
|
|
||||||
|
// load default node page
|
||||||
|
this.loadNodePage(node.destination_hash, this.defaultNodePagePath);
|
||||||
|
|
||||||
|
},
|
||||||
|
async onRenameFavourite(favourite) {
|
||||||
|
|
||||||
|
// ask user for new display name
|
||||||
|
const displayName = await DialogUtils.prompt("Rename this favourite");
|
||||||
|
if(displayName == null){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
// rename on server
|
||||||
|
await axios.post(`/api/v1/favourites/${favourite.destination_hash}/rename`, {
|
||||||
|
display_name: displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// reload favourites
|
||||||
|
await this.getFavourites();
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
console.log(e);
|
||||||
|
DialogUtils.alert("Failed to rename favourite");
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
async onRemoveFavourite(favourite) {
|
||||||
|
|
||||||
|
// ask user to confirm
|
||||||
|
if(!await DialogUtils.confirm("Are you sure you want to remove this favourite?")){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeFavourite(favourite);
|
||||||
|
|
||||||
|
},
|
||||||
|
onCloseNodeViewer: function() {
|
||||||
|
|
||||||
|
// clear selected node
|
||||||
|
this.selectedNode = null;
|
||||||
|
|
||||||
|
if(this.isPopoutMode){
|
||||||
|
window.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update current route
|
||||||
|
const routeName = this.isPopoutMode ? "nomadnetwork-popout" : "nomadnetwork";
|
||||||
|
const routeOptions = { name: routeName };
|
||||||
|
if(!this.isPopoutMode && this.$route?.query){
|
||||||
|
routeOptions.query = { ...this.$route.query };
|
||||||
|
}
|
||||||
|
this.$router.replace(routeOptions);
|
||||||
|
|
||||||
},
|
},
|
||||||
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
|
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
|
||||||
return `${destinationHash}:${pagePath}`;
|
return `${destinationHash}:${pagePath}`;
|
||||||
@@ -647,6 +1061,29 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
|
async identify(destinationHash) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// ask user to confirm
|
||||||
|
if(!await DialogUtils.confirm("Are you sure you want to identify yourself to this NomadNetwork Node? The page will reload after your identity has been sent.")){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// identify self to nomadnetwork node
|
||||||
|
await window.axios.post(`/api/v1/nomadnetwork/${destinationHash}/identify`);
|
||||||
|
|
||||||
|
// reload page
|
||||||
|
this.reloadNodePage();
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
DialogUtils.alert(e.response?.data?.message ?? "Failed to identify!");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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 {
|
||||||
|
|
||||||
@@ -694,6 +1131,25 @@ export default {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
renderedNodePageContent() {
|
||||||
|
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
|
||||||
|
},
|
||||||
|
cancelPageDownload() {
|
||||||
|
if(this.currentPageDownloadId !== null){
|
||||||
|
WebSocketConnection.send(JSON.stringify({
|
||||||
|
"type": "nomadnet.download.cancel",
|
||||||
|
"download_id": this.currentPageDownloadId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelFileDownload() {
|
||||||
|
if(this.currentFileDownloadId !== null){
|
||||||
|
WebSocketConnection.send(JSON.stringify({
|
||||||
|
"type": "nomadnet.download.cancel",
|
||||||
|
"download_id": this.currentFileDownloadId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,63 +1,94 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col w-80 min-w-80">
|
<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-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
|
|
||||||
<!-- search -->
|
<div class="flex">
|
||||||
<div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800">
|
<button @click="tab = 'favourites'" type="button" class="sidebar-tab" :class="{ 'sidebar-tab--active': tab === 'favourites' }">
|
||||||
<input
|
Favourites
|
||||||
v-model="nodesSearchTerm"
|
</button>
|
||||||
type="text"
|
<button @click="tab = 'announces'" type="button" class="sidebar-tab" :class="{ 'sidebar-tab--active': tab === 'announces' }">
|
||||||
:placeholder="`Search ${nodesCount} Nodes...`"
|
Announces
|
||||||
class="bg-gray-50 dark:bg-zinc-900 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:placeholder-gray-400"
|
</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>
|
||||||
<!-- nodes -->
|
<div class="flex-1 overflow-y-auto px-2 pb-4">
|
||||||
<div class="flex h-full overflow-y-auto">
|
<div v-if="searchedFavourites.length > 0" class="space-y-2 pt-2">
|
||||||
<div v-if="searchedNodes.length > 0" class="w-full">
|
<div
|
||||||
<div
|
v-for="favourite of searchedFavourites"
|
||||||
@click="onNodeClick(node)"
|
:key="favourite.destination_hash"
|
||||||
v-for="node of searchedNodes"
|
@click="onFavouriteClick(favourite)"
|
||||||
class="flex cursor-pointer p-2 border-l-2"
|
class="favourite-card"
|
||||||
:class="[
|
:class="[
|
||||||
node.destination_hash === selectedDestinationHash
|
favourite.destination_hash === selectedDestinationHash ? 'favourite-card--active' : '',
|
||||||
? 'bg-gray-100 dark:bg-zinc-800 border-blue-500'
|
draggingFavouriteHash === favourite.destination_hash ? 'favourite-card--dragging' : ''
|
||||||
: 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-900 hover:border-gray-200 dark:hover:border-zinc-700'
|
|
||||||
]"
|
]"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onFavouriteDragStart($event, favourite)"
|
||||||
|
@dragover.prevent="onFavouriteDragOver($event)"
|
||||||
|
@drop.prevent="onFavouriteDrop($event, favourite)"
|
||||||
|
@dragend="onFavouriteDragEnd"
|
||||||
>
|
>
|
||||||
<div class="my-auto mr-2">
|
<div class="favourite-card__icon">
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 p-2 rounded">
|
<MaterialDesignIcon icon-name="server-network" class="w-5 h-5"/>
|
||||||
<MaterialDesignIcon icon-name="server-network-outline" class="w-6 h-6"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex-1">
|
||||||
<div class="text-gray-900 dark:text-gray-100">{{ node.display_name }}</div>
|
<div class="text-sm font-semibold text-gray-900 dark:text-white truncate" :title="favourite.display_name">{{ favourite.display_name }}</div>
|
||||||
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatTimeAgo(node.updated_at) }}</div>
|
<div class="text-xs text-gray-500 dark:text-gray-400">{{ formatDestinationHash(favourite.destination_hash) }}</div>
|
||||||
</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>
|
</div>
|
||||||
<div v-else class="mx-auto my-auto text-center leading-5">
|
<div v-else class="empty-state">
|
||||||
<!-- no nodes at all -->
|
<MaterialDesignIcon icon-name="star-outline" class="w-8 h-8"/>
|
||||||
<div v-if="nodesCount === 0" class="flex flex-col">
|
<div class="font-semibold">No favourites</div>
|
||||||
<div class="mx-auto mb-1">
|
<div class="text-sm text-gray-500 dark:text-gray-400">Add nodes from the announces tab.</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 text-gray-500 dark:text-gray-400">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="font-semibold text-gray-900 dark:text-gray-100">No Nodes Discovered</div>
|
|
||||||
<div class="text-gray-500 dark:text-gray-400">Waiting for a node to announce!</div>
|
|
||||||
</div>
|
|
||||||
<!-- is searching, but no results -->
|
|
||||||
<div v-if="nodesSearchTerm !== '' && nodesCount > 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 text-gray-500 dark:text-gray-400">
|
|
||||||
<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 text-gray-900 dark:text-gray-100">No Search Results</div>
|
|
||||||
<div class="text-gray-500 dark:text-gray-400">Your search didn't match any Nodes!</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -65,26 +96,119 @@
|
|||||||
|
|
||||||
import Utils from "../../js/Utils";
|
import Utils from "../../js/Utils";
|
||||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
|
import DropDownMenu from "../DropDownMenu.vue";
|
||||||
|
import IconButton from "../IconButton.vue";
|
||||||
|
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'NomadNetworkSidebar',
|
name: 'NomadNetworkSidebar',
|
||||||
components: {MaterialDesignIcon},
|
components: {DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon},
|
||||||
props: {
|
props: {
|
||||||
nodes: Object,
|
nodes: Object,
|
||||||
|
favourites: Array,
|
||||||
selectedDestinationHash: String,
|
selectedDestinationHash: String,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
tab: "favourites",
|
||||||
|
favouritesSearchTerm: "",
|
||||||
nodesSearchTerm: "",
|
nodesSearchTerm: "",
|
||||||
|
favouritesOrder: [],
|
||||||
|
draggingFavouriteHash: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadFavouriteOrder();
|
||||||
|
this.ensureFavouriteOrder();
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
favourites: {
|
||||||
|
handler() {
|
||||||
|
this.ensureFavouriteOrder();
|
||||||
|
},
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onNodeClick(node) {
|
onNodeClick(node) {
|
||||||
this.$emit("node-click", node);
|
this.$emit("node-click", node);
|
||||||
},
|
},
|
||||||
|
onFavouriteClick(favourite) {
|
||||||
|
this.onNodeClick(favourite);
|
||||||
|
},
|
||||||
|
onRenameFavourite(favourite) {
|
||||||
|
this.$emit("rename-favourite", favourite);
|
||||||
|
},
|
||||||
|
onRemoveFavourite(favourite) {
|
||||||
|
this.$emit("remove-favourite", favourite);
|
||||||
|
},
|
||||||
|
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) {
|
formatTimeAgo: function(datetimeString) {
|
||||||
return Utils.formatTimeAgo(datetimeString);
|
return Utils.formatTimeAgo(datetimeString);
|
||||||
},
|
},
|
||||||
|
formatDestinationHash: function(destinationHash) {
|
||||||
|
return Utils.formatDestinationHash(destinationHash);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
nodesCount() {
|
nodesCount() {
|
||||||
@@ -107,6 +231,51 @@ export default {
|
|||||||
return matchesDisplayName || matchesDestinationHash;
|
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>
|
</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),
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// confirm user wants to update their icon
|
// confirm user wants to update their icon
|
||||||
if(!confirm("Are you sure you want to set this as your profile icon?")){
|
if(!await DialogUtils.confirm("Are you sure you want to set this as your profile icon?")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ export default {
|
|||||||
async removeProfileIcon() {
|
async removeProfileIcon() {
|
||||||
|
|
||||||
// confirm user wants to remove their icon
|
// confirm user wants to remove their icon
|
||||||
if(!confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){
|
if(!await DialogUtils.confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,78 @@
|
|||||||
<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-gray-50 dark:bg-zinc-950">
|
||||||
|
|
||||||
<!-- search -->
|
<!-- search and sort -->
|
||||||
<div v-if="propagationNodes.length > 0" class="flex bg-white dark:bg-zinc-800 p-1 border-b border-gray-300 dark:border-zinc-700">
|
<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="w-full 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="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>
|
</div>
|
||||||
|
|
||||||
<!-- propagation nodes -->
|
<!-- propagation nodes -->
|
||||||
<div class="h-full overflow-y-auto">
|
<div class="h-full overflow-y-auto px-4 py-4">
|
||||||
<div v-if="searchedPropagationNodes.length > 0" class="p-2 space-y-2 w-full">
|
<div v-if="paginatedNodes.length > 0" class="space-y-3 w-full">
|
||||||
<div v-for="propagationNode of searchedPropagationNodes" class="border dark:border-zinc-700 rounded bg-white dark:bg-zinc-800 shadow">
|
<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-1 flex">
|
<div class="p-4 flex items-center gap-3">
|
||||||
<div class="my-auto">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ propagationNode.operator_display_name ?? "Unknown Operator" }}</div>
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-300"><{{ propagationNode.destination_hash }}></div>
|
<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>
|
||||||
<div class="ml-auto my-auto">
|
<div class="flex-shrink-0">
|
||||||
<button v-if="config.lxmf_preferred_propagation_node_destination_hash === propagationNode.destination_hash" @click="stopUsingPropagationNode" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-red-500 dark:bg-red-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-red-400 dark:hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500 dark:focus-visible:outline-red-600">
|
<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 Node
|
Stop Using
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="usePropagationNode(propagationNode.destination_hash)" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-600">
|
<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
|
Set as Preferred
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50 dark:bg-zinc-900 p-1">
|
|
||||||
<div class="text-gray-500 dark:text-gray-400 text-sm">
|
|
||||||
<span>Announced {{ formatTimeAgo(propagationNode.updated_at) }}</span>
|
|
||||||
<span v-if="propagationNode.is_propagation_enabled === false">
|
|
||||||
<span> • <span class="text-red-500 dark:text-red-400">Disabled by Operator</span></span>
|
|
||||||
</span>
|
|
||||||
<span v-if="config.lxmf_preferred_propagation_node_destination_hash === propagationNode.destination_hash">
|
|
||||||
<span> • <span class="text-green-500 dark:text-green-400">Preferred</span></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex h-full">
|
|
||||||
|
<!-- 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">
|
<div class="mx-auto my-auto text-center leading-5 text-gray-900 dark:text-gray-100">
|
||||||
|
|
||||||
<!-- no propagation nodes at all -->
|
<!-- no propagation nodes at all -->
|
||||||
@@ -49,8 +84,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">No Propagation Nodes</div>
|
<div class="font-semibold">No Propagation Nodes</div>
|
||||||
<div>Check back later, once someone has announced.</div>
|
<div>Check back later, once someone has announced.</div>
|
||||||
<div class="mt-2">
|
<div class="mt-4">
|
||||||
<button @click="loadPropagationNodes" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-600">
|
<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
|
Reload
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,10 +118,13 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
searchTerm: "",
|
searchTerm: "",
|
||||||
|
sortBy: "preferred",
|
||||||
propagationNodes: [],
|
propagationNodes: [],
|
||||||
config: {
|
config: {
|
||||||
lxmf_preferred_propagation_node_destination_hash: null,
|
lxmf_preferred_propagation_node_destination_hash: null,
|
||||||
},
|
},
|
||||||
|
currentPage: 1,
|
||||||
|
itemsPerPage: 20,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
@@ -158,6 +196,14 @@ export default {
|
|||||||
return Utils.formatTimeAgo(datetimeString);
|
return Utils.formatTimeAgo(datetimeString);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
searchTerm() {
|
||||||
|
this.currentPage = 1;
|
||||||
|
},
|
||||||
|
sortBy() {
|
||||||
|
this.currentPage = 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
searchedPropagationNodes() {
|
searchedPropagationNodes() {
|
||||||
return this.propagationNodes.filter((propagationNode) => {
|
return this.propagationNodes.filter((propagationNode) => {
|
||||||
@@ -167,6 +213,79 @@ export default {
|
|||||||
return matchesOperatorDisplayName || matchesDestinationHash;
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,129 +1,212 @@
|
|||||||
<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 space-y-2 p-2">
|
<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">
|
||||||
|
|
||||||
<!-- appearance -->
|
<!-- hero card -->
|
||||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
<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 border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Appearance</div>
|
<div class="flex flex-col md:flex-row md:items-center gap-4">
|
||||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
<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="p-2">
|
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ config.display_name }}</div>
|
||||||
<div class="flex">
|
<div class="text-sm text-gray-600 dark:text-gray-300">Manage your identity, transport participation and LXMF defaults.</div>
|
||||||
<select v-model="config.theme" @change="onThemeChange" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
|
</div>
|
||||||
<option value="light">Light Theme</option>
|
<div class="flex flex-col sm:flex-row gap-2">
|
||||||
<option value="dark">Dark Theme</option>
|
<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">
|
||||||
</select>
|
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||||
</div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- interfaces -->
|
<!-- settings grid -->
|
||||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Interfaces</div>
|
|
||||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
|
||||||
|
|
||||||
<div class="p-2">
|
<!-- Appearance -->
|
||||||
<div class="flex items-start">
|
<section class="glass-card">
|
||||||
<div class="flex items-center h-5">
|
<header class="glass-card__header">
|
||||||
<input v-model="config.show_suggested_community_interfaces" @change="onShowSuggestedCommunityInterfacesChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
|
<div>
|
||||||
</div>
|
<div class="glass-card__eyebrow">Personalise</div>
|
||||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Show Community Interfaces</label>
|
<h2>Appearance</h2>
|
||||||
|
<p>Switch between light and dark presets anytime.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, community interfaces will be shown on the Add Interface page.</div>
|
</header>
|
||||||
</div>
|
<div class="glass-card__body space-y-3">
|
||||||
|
<select v-model="config.theme" @change="onThemeChange" class="input-field">
|
||||||
</div>
|
<option value="light">Light Theme</option>
|
||||||
</div>
|
<option value="dark">Dark Theme</option>
|
||||||
|
</select>
|
||||||
<!-- messages -->
|
<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 class="bg-white dark:bg-zinc-800 rounded shadow">
|
<div>Live preview updates instantly.</div>
|
||||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Messages</div>
|
<span class="inline-flex items-center gap-1 text-blue-500 dark:text-blue-300 text-xs font-semibold uppercase">
|
||||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
|
||||||
|
Realtime
|
||||||
<div class="p-2">
|
</span>
|
||||||
<div class="flex items-start">
|
|
||||||
<div class="flex items-center h-5">
|
|
||||||
<input v-model="config.auto_resend_failed_messages_when_announce_received" @change="onAutoResendFailedMessagesWhenAnnounceReceivedChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
|
|
||||||
</div>
|
|
||||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Auto resend</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, failed messages will auto resend when an announce is received from the intended destination.</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="p-2">
|
<!-- Transport -->
|
||||||
<div class="flex items-start">
|
<section class="glass-card">
|
||||||
<div class="flex items-center h-5">
|
<header class="glass-card__header">
|
||||||
<input v-model="config.allow_auto_resending_failed_messages_with_attachments" @change="onAllowAutoResendingFailedMessagesWithAttachmentsChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
|
<div>
|
||||||
</div>
|
<div class="glass-card__eyebrow">Reticulum</div>
|
||||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Allow resending with attachments</label>
|
<h2>Transport Mode</h2>
|
||||||
|
<p>Relay paths and traffic for nearby peers.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, failed messages that have attachments are allowed to auto resend.</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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="p-2">
|
<!-- Interfaces -->
|
||||||
<div class="flex items-start">
|
<section class="glass-card">
|
||||||
<div class="flex items-center h-5">
|
<header class="glass-card__header">
|
||||||
<input v-model="config.auto_send_failed_messages_to_propagation_node" @change="onAutoSendFailedMessagesToPropagationNodeChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
|
<div>
|
||||||
</div>
|
<div class="glass-card__eyebrow">Adapters</div>
|
||||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Auto send to propagation node</label>
|
<h2>Interfaces</h2>
|
||||||
|
<p>Show curated community configs inside the interface wizard.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, messages that fail to send will be sent to the configured propagation node.</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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
<!-- Messages -->
|
||||||
</div>
|
<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 -->
|
<!-- Propagation nodes -->
|
||||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
<section class="glass-card lg:col-span-2">
|
||||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">
|
<header class="glass-card__header">
|
||||||
<div class="my-auto mr-auto">Propagation Nodes</div>
|
<div>
|
||||||
<div class="my-auto">
|
<div class="glass-card__eyebrow">LXMF</div>
|
||||||
<RouterLink :to="{ name: 'propagation-nodes' }" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-500">
|
<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
|
Browse Nodes
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</header>
|
||||||
</div>
|
<div class="glass-card__body space-y-5">
|
||||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
<div class="info-callout">
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
<div class="p-2">
|
<li>Propagation nodes hold messages securely until recipients sync again.</li>
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
<li>Nodes peer with each other to distribute encrypted payloads.</li>
|
||||||
<ul class="list-disc list-inside">
|
<li>Most nodes retain data ~30 days, then discard undelivered items.</li>
|
||||||
<li>When you send a message, the intended recipient may be offline and your message will fail to send.</li>
|
|
||||||
<li>Instead, messages can be sent to propagation nodes, which store the messages and allow recipients to retrieve them when they're next online.</li>
|
|
||||||
<li>Propagation nodes automatically peer and sync messages with each other, creating an encrypted, distributed message store.</li>
|
|
||||||
<li>By default, propagation nodes store messages for up to 30 days. If the recipient hasn't retrieved it by then, the message will be lost.</li>
|
|
||||||
<li>At this time, delivery reports are unavailable for messages sent to propagation nodes.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<label class="setting-toggle">
|
||||||
|
<input type="checkbox" v-model="config.lxmf_local_propagation_node_enabled" @change="onLxmfLocalPropagationNodeEnabledChange">
|
||||||
<div class="p-2">
|
<span class="setting-toggle__label">
|
||||||
<div class="flex items-start">
|
<span class="setting-toggle__title">Run a local propagation node</span>
|
||||||
<div class="flex items-center h-5">
|
<span class="setting-toggle__description">MeshChat will announce and maintain a node using this local destination hash.</span>
|
||||||
<input v-model="config.lxmf_local_propagation_node_enabled" @change="onLxmfLocalPropagationNodeEnabledChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
|
<span class="setting-toggle__hint monospace-field">{{ config.lxmf_local_propagation_node_address_hash || '—' }}</span>
|
||||||
</div>
|
</span>
|
||||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Local Propagation Node</label>
|
</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>
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, MeshChat will run a Propagation Node and announce it with the following address for other clients to use.</div>
|
<div class="space-y-2">
|
||||||
<div class="flex">
|
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Auto Sync Interval</div>
|
||||||
<input disabled v-model="config.lxmf_local_propagation_node_address_hash" type="text" class="bg-gray-200 dark:bg-zinc-800 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">
|
<select v-model="config.lxmf_preferred_propagation_node_auto_sync_interval_seconds" @change="onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange" class="input-field">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-2">
|
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Preferred Propagation Node</div>
|
|
||||||
<div class="flex">
|
|
||||||
<input v-model="config.lxmf_preferred_propagation_node_destination_hash" @input="onLxmfPreferredPropagationNodeDestinationHashChange" type="text" placeholder="Destination Hash. e.g: a39610c89d18bb48c73e429582423c24" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-300">This is the propagation node your messages will be sent to and retrieved from.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-2">
|
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Auto Sync Interval</div>
|
|
||||||
<div class="flex">
|
|
||||||
<select v-model="config.lxmf_preferred_propagation_node_auto_sync_interval_seconds" @change="onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange" 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">
|
|
||||||
<option value="0">Disabled</option>
|
<option value="0">Disabled</option>
|
||||||
<option value="900">Every 15 Minutes</option>
|
<option value="900">Every 15 Minutes</option>
|
||||||
<option value="1800">Every 30 Minutes</option>
|
<option value="1800">Every 30 Minutes</option>
|
||||||
@@ -133,16 +216,23 @@
|
|||||||
<option value="43200">Every 12 Hours</option>
|
<option value="43200">Every 12 Hours</option>
|
||||||
<option value="86400">Every 24 Hours</option>
|
<option value="86400">Every 24 Hours</option>
|
||||||
</select>
|
</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>
|
||||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
<div v-if="config.lxmf_local_propagation_node_enabled" class="space-y-2">
|
||||||
<span v-if="config.lxmf_preferred_propagation_node_last_synced_at">Last Synced: {{ formatSecondsAgo(config.lxmf_preferred_propagation_node_last_synced_at) }}</span>
|
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Propagation Node Stamp Cost</div>
|
||||||
<span v-else>Last Synced: Never</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -150,9 +240,14 @@
|
|||||||
<script>
|
<script>
|
||||||
import Utils from "../../js/Utils";
|
import Utils from "../../js/Utils";
|
||||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||||
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'SettingsPage',
|
name: 'SettingsPage',
|
||||||
|
components: {
|
||||||
|
MaterialDesignIcon,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
config: {
|
config: {
|
||||||
@@ -163,12 +258,15 @@ export default {
|
|||||||
lxmf_local_propagation_node_enabled: null,
|
lxmf_local_propagation_node_enabled: null,
|
||||||
lxmf_preferred_propagation_node_destination_hash: null,
|
lxmf_preferred_propagation_node_destination_hash: null,
|
||||||
},
|
},
|
||||||
|
copyToast: null,
|
||||||
|
copyToastTimeout: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
|
|
||||||
// stop listening for websocket messages
|
// stop listening for websocket messages
|
||||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||||
|
clearTimeout(this.copyToastTimeout);
|
||||||
|
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -207,6 +305,25 @@ export default {
|
|||||||
console.log(e);
|
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() {
|
async onThemeChange() {
|
||||||
await this.updateConfig({
|
await this.updateConfig({
|
||||||
"theme": this.config.theme,
|
"theme": this.config.theme,
|
||||||
@@ -247,9 +364,109 @@ export default {
|
|||||||
"lxmf_preferred_propagation_node_auto_sync_interval_seconds": this.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds,
|
"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) {
|
formatSecondsAgo: function(seconds) {
|
||||||
return Utils.formatSecondsAgo(seconds);
|
return Utils.formatSecondsAgo(seconds);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -1,60 +1,67 @@
|
|||||||
<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 space-y-2 p-2">
|
<div class="overflow-y-auto space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
|
||||||
|
|
||||||
<!-- appearance -->
|
<div class="glass-card space-y-3">
|
||||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Utilities</div>
|
||||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Tools</div>
|
<div class="text-2xl font-semibold text-gray-900 dark:text-white">Power tools for operators</div>
|
||||||
<div class="dark:divide-zinc-700 text-gray-900 dark:text-gray-100 p-2">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
A collection of useful tools bundled with MeshChat
|
Diagnostics and firmware helpers ship with MeshChat so you can troubleshoot peers without leaving the console.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ping -->
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<RouterLink :to="{ name: 'ping' }" class="group flex bg-white dark:bg-zinc-800 p-2 rounded shadow hover:bg-gray-50 dark:hover:bg-zinc-700">
|
<RouterLink :to="{ name: 'ping' }" class="tool-card glass-card">
|
||||||
<div class="mr-2">
|
<div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
|
||||||
<div class="flex bg-gray-300 text-gray-500 rounded shadow p-2">
|
<MaterialDesignIcon icon-name="radar" class="w-6 h-6"/>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-10">
|
|
||||||
<path fill-rule="evenodd" d="M14.615 1.595a.75.75 0 0 1 .359.852L12.982 9.75h7.268a.75.75 0 0 1 .548 1.262l-10.5 11.25a.75.75 0 0 1-1.272-.71l1.992-7.302H3.75a.75.75 0 0 1-.548-1.262l10.5-11.25a.75.75 0 0 1 .913-.143Z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex-1">
|
||||||
<div class="my-auto mr-auto dark:text-gray-200">
|
<div class="tool-card__title">Ping</div>
|
||||||
<div class="font-bold">Ping</div>
|
<div class="tool-card__description">Latency test for any LXMF destination hash with live status.</div>
|
||||||
<div class="text-sm">Allows you to ping a destination hash.</div>
|
</div>
|
||||||
</div>
|
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron"/>
|
||||||
<div class="my-auto text-gray-400 group-hover:text-gray-500">
|
</RouterLink>
|
||||||
<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="m8.25 4.5 7.5 7.5-7.5 7.5"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<!-- rnode flasher -->
|
<a target="_blank" href="/rnode-flasher/index.html" class="tool-card glass-card">
|
||||||
<a target="_blank" href="/rnode-flasher/index.html" class="group flex bg-white dark:bg-zinc-800 p-2 rounded shadow hover:bg-gray-50 dark:hover:bg-zinc-700">
|
<div class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200">
|
||||||
<div class="mr-2">
|
<img src="/rnode-flasher/reticulum_logo_512.png" class="w-8 h-8 rounded-full" alt="RNode"/>
|
||||||
<div class="flex bg-gray-300 text-white rounded shadow">
|
|
||||||
<img src="/rnode-flasher/reticulum_logo_512.png" class="size-14"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex-1">
|
||||||
<div class="my-auto mr-auto dark:text-gray-200">
|
<div class="tool-card__title">RNode Flasher</div>
|
||||||
<div class="font-bold">RNode Flasher</div>
|
<div class="tool-card__description">Flash and update RNode adapters without touching the command line.</div>
|
||||||
<div class="text-sm">Flash RNode firmware to supported devices.</div>
|
</div>
|
||||||
</div>
|
<MaterialDesignIcon icon-name="open-in-new" class="tool-card__chevron"/>
|
||||||
<div class="my-auto text-gray-400 group-hover:text-gray-500">
|
</a>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
</div>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
export default {
|
export default {
|
||||||
name: 'ToolsPage',
|
name: 'ToolsPage',
|
||||||
|
components: {
|
||||||
|
MaterialDesignIcon,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</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
src/frontend/js/Codec2Loader.js
Normal file
62
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,6 +10,16 @@ class DialogUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static confirm(message) {
|
||||||
|
if(window.electron){
|
||||||
|
// running inside electron, use ipc confirm
|
||||||
|
return window.electron.confirm(message);
|
||||||
|
} else {
|
||||||
|
// running inside normal browser, use browser alert
|
||||||
|
return window.confirm(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async prompt(message) {
|
static async prompt(message) {
|
||||||
if(window.electron){
|
if(window.electron){
|
||||||
// running inside electron, use ipc prompt
|
// running inside electron, use ipc prompt
|
||||||
|
|||||||
28
src/frontend/js/DownloadUtils.js
Normal file
28
src/frontend/js/DownloadUtils.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
class DownloadUtils {
|
||||||
|
|
||||||
|
static downloadFile(filename, blob) {
|
||||||
|
|
||||||
|
// create object url for blob
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// create hidden link element to download blob
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = filename;
|
||||||
|
link.style.display = "none";
|
||||||
|
document.body.append(link);
|
||||||
|
|
||||||
|
// click link to download file in browser
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
// link element is no longer needed
|
||||||
|
link.remove();
|
||||||
|
|
||||||
|
// revoke object url to clear memory
|
||||||
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DownloadUtils;
|
||||||
@@ -2,6 +2,13 @@ import moment from "moment";
|
|||||||
|
|
||||||
class Utils {
|
class Utils {
|
||||||
|
|
||||||
|
static formatDestinationHash(destinationHashHex) {
|
||||||
|
const bytesPerSide = 4;
|
||||||
|
const leftSide = destinationHashHex.substring(0, bytesPerSide * 2);
|
||||||
|
const rightSide = destinationHashHex.substring(destinationHashHex.length - bytesPerSide * 2);
|
||||||
|
return `<${leftSide}...${rightSide}>`
|
||||||
|
}
|
||||||
|
|
||||||
static formatBytes(bytes) {
|
static formatBytes(bytes) {
|
||||||
|
|
||||||
if(bytes === 0){
|
if(bytes === 0){
|
||||||
@@ -18,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));
|
||||||
@@ -120,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){
|
||||||
@@ -143,7 +173,7 @@ class Utils {
|
|||||||
|
|
||||||
static isInterfaceEnabled(iface) {
|
static isInterfaceEnabled(iface) {
|
||||||
const rawValue = iface.enabled ?? iface.interface_enabled;
|
const rawValue = iface.enabled ?? iface.interface_enabled;
|
||||||
const value = rawValue?.toLowerCase();
|
const value = rawValue?.toString()?.toLowerCase();
|
||||||
return value === "on" || value === "yes" || value === "true";
|
return value === "on" || value === "yes" || value === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,18 @@ import mitt from 'mitt';
|
|||||||
class WebSocketConnection {
|
class WebSocketConnection {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
||||||
this.emitter = mitt();
|
this.emitter = mitt();
|
||||||
this.reconnect();
|
this.reconnect();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ping websocket server every 30 seconds
|
||||||
|
* this helps to prevent the underlying tcp connection from going stale when there's no traffic for a long time
|
||||||
|
*/
|
||||||
|
setInterval(() => {
|
||||||
|
this.ping();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// add event listener
|
// add event listener
|
||||||
@@ -47,6 +57,16 @@ class WebSocketConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ping() {
|
||||||
|
try {
|
||||||
|
this.send(JSON.stringify({
|
||||||
|
"type": "ping",
|
||||||
|
}));
|
||||||
|
} catch(e) {
|
||||||
|
// ignore error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new WebSocketConnection();
|
export default new WebSocketConnection();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -46,7 +47,15 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "messages",
|
name: "messages",
|
||||||
path: '/messages',
|
path: '/messages/:destinationHash?',
|
||||||
|
props: true,
|
||||||
|
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "messages-popout",
|
||||||
|
path: '/popout/messages/:destinationHash?',
|
||||||
|
props: true,
|
||||||
|
meta: { popoutType: "conversation", isPopout: true },
|
||||||
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -56,7 +65,15 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nomadnetwork",
|
name: "nomadnetwork",
|
||||||
path: '/nomadnetwork',
|
path: '/nomadnetwork/:destinationHash?',
|
||||||
|
props: true,
|
||||||
|
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nomadnetwork-popout",
|
||||||
|
path: '/popout/nomadnetwork/:destinationHash?',
|
||||||
|
props: true,
|
||||||
|
meta: { popoutType: "nomad", isPopout: true },
|
||||||
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -84,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();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -79,6 +79,8 @@ class RNode {
|
|||||||
ROM_UNLOCK_BYTE = 0xF8;
|
ROM_UNLOCK_BYTE = 0xF8;
|
||||||
CMD_HASHES = 0x60;
|
CMD_HASHES = 0x60;
|
||||||
CMD_FW_UPD = 0x61;
|
CMD_FW_UPD = 0x61;
|
||||||
|
CMD_DISP_ROT = 0x67;
|
||||||
|
CMD_DISP_RCND = 0x68;
|
||||||
|
|
||||||
CMD_BT_CTRL = 0x46;
|
CMD_BT_CTRL = 0x46;
|
||||||
CMD_BT_PIN = 0x62;
|
CMD_BT_PIN = 0x62;
|
||||||
@@ -121,8 +123,10 @@ class RNode {
|
|||||||
|
|
||||||
constructor(serialPort) {
|
constructor(serialPort) {
|
||||||
this.serialPort = serialPort;
|
this.serialPort = serialPort;
|
||||||
this.readable = serialPort.readable;
|
this.reader = serialPort.readable.getReader();
|
||||||
this.writable = serialPort.writable;
|
this.writable = serialPort.writable;
|
||||||
|
this.callbacks = {};
|
||||||
|
this.readLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
static async fromSerialPort(serialPort) {
|
static async fromSerialPort(serialPort) {
|
||||||
@@ -137,11 +141,21 @@ class RNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
|
|
||||||
|
// release reader lock
|
||||||
|
try {
|
||||||
|
this.reader.releaseLock();
|
||||||
|
} catch(e) {
|
||||||
|
//console.log("failed to release lock on serial port readable, ignoring...", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// close serial port
|
||||||
try {
|
try {
|
||||||
await this.serialPort.close();
|
await this.serialPort.close();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.log("failed to close serial port, ignoring...", e);
|
//console.log("failed to close serial port, ignoring...", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async write(bytes) {
|
async write(bytes) {
|
||||||
@@ -153,79 +167,100 @@ class RNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async readFromSerialPort(timeoutMillis) {
|
async readLoop() {
|
||||||
return new Promise(async (resolve, reject) => {
|
try {
|
||||||
|
let buffer = [];
|
||||||
|
let inFrame = false;
|
||||||
|
while(true){
|
||||||
|
|
||||||
// create reader
|
// read kiss frames until reader indicates it's done
|
||||||
const reader = this.readable.getReader();
|
const { value, done } = await this.reader.read();
|
||||||
|
if(done){
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// timeout after provided millis
|
// read kiss frames
|
||||||
if(timeoutMillis != null){
|
for(const byte of value){
|
||||||
setTimeout(() => {
|
if(byte === this.KISS_FEND){
|
||||||
reader.releaseLock();
|
if(inFrame){
|
||||||
reject("timeout");
|
// End of frame
|
||||||
}, timeoutMillis);
|
const decodedFrame = this.decodeKissFrame(buffer);
|
||||||
}
|
if(decodedFrame){
|
||||||
|
this.onCommandReceived(decodedFrame);
|
||||||
// attempt to read kiss frame
|
} else {
|
||||||
try {
|
console.warn("Invalid frame ignored.");
|
||||||
let buffer = [];
|
|
||||||
while(true){
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if(done){
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if(value){
|
|
||||||
for(let byte of value){
|
|
||||||
buffer.push(byte);
|
|
||||||
if(byte === this.KISS_FEND){
|
|
||||||
if(buffer.length > 1){
|
|
||||||
resolve(this.handleKISSFrame(buffer));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buffer = [this.KISS_FEND]; // Start new frame
|
|
||||||
}
|
}
|
||||||
|
buffer = [];
|
||||||
}
|
}
|
||||||
|
inFrame = !inFrame;
|
||||||
|
} else if(inFrame) {
|
||||||
|
buffer.push(byte);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading from serial port: ', error);
|
}
|
||||||
} finally {
|
} catch(error) {
|
||||||
reader.releaseLock();
|
|
||||||
|
// ignore error if reader was released
|
||||||
|
if(error instanceof TypeError){
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
console.error('Error reading from serial port: ', error);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
this.reader.releaseLock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKISSFrame(frame) {
|
onCommandReceived(data) {
|
||||||
|
try {
|
||||||
|
|
||||||
let data = [];
|
// get received command and bytes from data
|
||||||
|
const [ command, ...bytes ] = data;
|
||||||
|
console.log("onCommandReceived", "0x" + command.toString(16), bytes);
|
||||||
|
|
||||||
|
// find callback for received command
|
||||||
|
const callback = this.callbacks[command];
|
||||||
|
if(!callback){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fire callback
|
||||||
|
callback(bytes);
|
||||||
|
|
||||||
|
// forget callback
|
||||||
|
delete this.callbacks[command];
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
console.log("failed to handle received command", data, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decodeKissFrame(frame) {
|
||||||
|
|
||||||
|
const data = [];
|
||||||
let escaping = false;
|
let escaping = false;
|
||||||
|
|
||||||
// Skip the initial 0xC0 and process the rest
|
for(const byte of frame){
|
||||||
for(let i = 1; i < frame.length; i++){
|
if(escaping){
|
||||||
let byte = frame[i];
|
if(byte === this.KISS_TFEND){
|
||||||
if (escaping) {
|
|
||||||
if (byte === this.KISS_TFEND) {
|
|
||||||
data.push(this.KISS_FEND);
|
data.push(this.KISS_FEND);
|
||||||
} else if (byte === this.KISS_TFESC) {
|
} else if(byte === this.KISS_TFESC) {
|
||||||
data.push(this.KISS_FESC);
|
data.push(this.KISS_FESC);
|
||||||
|
} else {
|
||||||
|
return null; // Invalid escape sequence
|
||||||
}
|
}
|
||||||
escaping = false;
|
escaping = false;
|
||||||
|
} else if(byte === this.KISS_FESC) {
|
||||||
|
escaping = true;
|
||||||
} else {
|
} else {
|
||||||
if (byte === this.KISS_FESC) {
|
data.push(byte);
|
||||||
escaping = true;
|
|
||||||
} else if (byte === this.KISS_FEND) {
|
|
||||||
// Ignore the end frame delimiter
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
data.push(byte);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log('Received KISS frame data:', new Uint8Array(data));
|
// return null if incomplete escape at end
|
||||||
return data;
|
return escaping ? null : data;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +283,28 @@ class RNode {
|
|||||||
await this.write(this.createKissFrame(data));
|
await this.write(this.createKissFrame(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sends a command to the rnode, and resolves the promise with the result
|
||||||
|
async sendCommand(command, data) {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// listen for response
|
||||||
|
this.callbacks[command] = (response) => {
|
||||||
|
resolve(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
// send command
|
||||||
|
await this.sendKissCommand([
|
||||||
|
command,
|
||||||
|
...data,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async reset() {
|
async reset() {
|
||||||
await this.sendKissCommand([
|
await this.sendKissCommand([
|
||||||
this.CMD_RESET,
|
this.CMD_RESET,
|
||||||
@@ -256,30 +313,42 @@ class RNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async detect() {
|
async detect() {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
try {
|
||||||
|
|
||||||
// ask if device is rnode
|
// timeout after provided millis
|
||||||
await this.sendKissCommand([
|
const timeout = setTimeout(() => {
|
||||||
this.CMD_DETECT,
|
resolve(false);
|
||||||
this.DETECT_REQ,
|
}, 2000);
|
||||||
]);
|
|
||||||
|
|
||||||
// read response from device
|
// detect rnode
|
||||||
const [ command, responseByte ] = await this.readFromSerialPort();
|
const response = await this.sendCommand(this.CMD_DETECT, [
|
||||||
|
this.DETECT_REQ,
|
||||||
|
]);
|
||||||
|
|
||||||
// device is an rnode if response is as expected
|
// we no longer want to timeout
|
||||||
return command === this.CMD_DETECT && responseByte === this.DETECT_RESP;
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
// device is an rnode if response is as expected
|
||||||
|
const [ responseByte ] = response;
|
||||||
|
const isRnode = responseByte === this.DETECT_RESP;
|
||||||
|
resolve(isRnode);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFirmwareVersion() {
|
async getFirmwareVersion() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_FW_VERSION, [
|
||||||
this.CMD_FW_VERSION,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
var [ command, majorVersion, minorVersion ] = await this.readFromSerialPort();
|
var [ majorVersion, minorVersion ] = response;
|
||||||
if(minorVersion.length === 1){
|
if(minorVersion.length === 1){
|
||||||
minorVersion = "0" + minorVersion;
|
minorVersion = "0" + minorVersion;
|
||||||
}
|
}
|
||||||
@@ -291,99 +360,91 @@ class RNode {
|
|||||||
|
|
||||||
async getPlatform() {
|
async getPlatform() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_PLATFORM, [
|
||||||
this.CMD_PLATFORM,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, platformByte ] = await this.readFromSerialPort();
|
const [ platformByte ] = response;
|
||||||
return platformByte;
|
return platformByte;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMcu() {
|
async getMcu() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_MCU, [
|
||||||
this.CMD_MCU,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, mcuByte ] = await this.readFromSerialPort();
|
const [ mcuByte ] = response;
|
||||||
return mcuByte;
|
return mcuByte;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBoard() {
|
async getBoard() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_BOARD, [
|
||||||
this.CMD_BOARD,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, boardByte ] = await this.readFromSerialPort();
|
const [ boardByte ] = response;
|
||||||
return boardByte;
|
return boardByte;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeviceHash() {
|
async getDeviceHash() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_DEV_HASH, [
|
||||||
this.CMD_DEV_HASH,
|
|
||||||
0x01, // anything != 0x00
|
0x01, // anything != 0x00
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...deviceHash ] = await this.readFromSerialPort();
|
const [ ...deviceHash ] = response;
|
||||||
return deviceHash;
|
return deviceHash;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTargetFirmwareHash() {
|
async getTargetFirmwareHash() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_HASHES, [
|
||||||
this.CMD_HASHES,
|
|
||||||
this.HASH_TYPE_TARGET_FIRMWARE,
|
this.HASH_TYPE_TARGET_FIRMWARE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, hashType, ...targetFirmwareHash ] = await this.readFromSerialPort();
|
const [ hashType, ...targetFirmwareHash ] = response;
|
||||||
return targetFirmwareHash;
|
return targetFirmwareHash;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFirmwareHash() {
|
async getFirmwareHash() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_HASHES, [
|
||||||
this.CMD_HASHES,
|
|
||||||
this.HASH_TYPE_FIRMWARE,
|
this.HASH_TYPE_FIRMWARE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, hashType, ...firmwareHash ] = await this.readFromSerialPort();
|
const [ hashType, ...firmwareHash ] = response;
|
||||||
return firmwareHash;
|
return firmwareHash;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRom() {
|
async getRom() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_ROM_READ, [
|
||||||
this.CMD_ROM_READ,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...eepromBytes ] = await this.readFromSerialPort();
|
const [ ...eepromBytes ] = response;
|
||||||
return eepromBytes;
|
return eepromBytes;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFrequency() {
|
async getFrequency() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_FREQUENCY, [
|
||||||
this.CMD_FREQUENCY,
|
|
||||||
// request frequency by sending zero as 4 bytes
|
// request frequency by sending zero as 4 bytes
|
||||||
0x00,
|
0x00,
|
||||||
0x00,
|
0x00,
|
||||||
@@ -392,7 +453,7 @@ class RNode {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...frequencyBytes ] = await this.readFromSerialPort();
|
const [ ...frequencyBytes ] = response;
|
||||||
|
|
||||||
// convert 4 bytes to 32bit integer representing frequency in hertz
|
// convert 4 bytes to 32bit integer representing frequency in hertz
|
||||||
const frequencyInHz = frequencyBytes[0] << 24 | frequencyBytes[1] << 16 | frequencyBytes[2] << 8 | frequencyBytes[3];
|
const frequencyInHz = frequencyBytes[0] << 24 | frequencyBytes[1] << 16 | frequencyBytes[2] << 8 | frequencyBytes[3];
|
||||||
@@ -402,8 +463,7 @@ class RNode {
|
|||||||
|
|
||||||
async getBandwidth() {
|
async getBandwidth() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_BANDWIDTH, [
|
||||||
this.CMD_BANDWIDTH,
|
|
||||||
// request bandwidth by sending zero as 4 bytes
|
// request bandwidth by sending zero as 4 bytes
|
||||||
0x00,
|
0x00,
|
||||||
0x00,
|
0x00,
|
||||||
@@ -412,7 +472,7 @@ class RNode {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...bandwidthBytes ] = await this.readFromSerialPort();
|
const [ ...bandwidthBytes ] = response;
|
||||||
|
|
||||||
// convert 4 bytes to 32bit integer representing bandwidth in hertz
|
// convert 4 bytes to 32bit integer representing bandwidth in hertz
|
||||||
const bandwidthInHz = bandwidthBytes[0] << 24 | bandwidthBytes[1] << 16 | bandwidthBytes[2] << 8 | bandwidthBytes[3];
|
const bandwidthInHz = bandwidthBytes[0] << 24 | bandwidthBytes[1] << 16 | bandwidthBytes[2] << 8 | bandwidthBytes[3];
|
||||||
@@ -422,69 +482,60 @@ class RNode {
|
|||||||
|
|
||||||
async getTxPower() {
|
async getTxPower() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_TXPOWER, [
|
||||||
this.CMD_TXPOWER,
|
|
||||||
0xFF, // request tx power
|
0xFF, // request tx power
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, txPower ] = await this.readFromSerialPort();
|
const [ txPower ] = response;
|
||||||
|
|
||||||
return txPower;
|
return txPower;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSpreadingFactor() {
|
async getSpreadingFactor() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_SF, [
|
||||||
this.CMD_SF,
|
|
||||||
0xFF, // request spreading factor
|
0xFF, // request spreading factor
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, spreadingFactor ] = await this.readFromSerialPort();
|
const [ spreadingFactor ] = response;
|
||||||
|
|
||||||
return spreadingFactor;
|
return spreadingFactor;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCodingRate() {
|
async getCodingRate() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_CR, [
|
||||||
this.CMD_CR,
|
|
||||||
0xFF, // request coding rate
|
0xFF, // request coding rate
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, codingRate ] = await this.readFromSerialPort();
|
const [ codingRate ] = response;
|
||||||
|
|
||||||
return codingRate;
|
return codingRate;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRadioState() {
|
async getRadioState() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_RADIO_STATE, [
|
||||||
this.CMD_RADIO_STATE,
|
|
||||||
0xFF, // request radio state
|
0xFF, // request radio state
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, radioState ] = await this.readFromSerialPort();
|
const [ radioState ] = response;
|
||||||
|
|
||||||
return radioState;
|
return radioState;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRxStat() {
|
async getRxStat() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_STAT_RX, [
|
||||||
this.CMD_STAT_RX,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...statBytes ] = await this.readFromSerialPort();
|
const [ ...statBytes ] = response;
|
||||||
|
|
||||||
// convert 4 bytes to 32bit integer
|
// convert 4 bytes to 32bit integer
|
||||||
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
|
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
|
||||||
@@ -494,13 +545,12 @@ class RNode {
|
|||||||
|
|
||||||
async getTxStat() {
|
async getTxStat() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_STAT_TX, [
|
||||||
this.CMD_STAT_TX,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...statBytes ] = await this.readFromSerialPort();
|
const [ ...statBytes ] = response;
|
||||||
|
|
||||||
// convert 4 bytes to 32bit integer
|
// convert 4 bytes to 32bit integer
|
||||||
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
|
const stat = statBytes[0] << 24 | statBytes[1] << 16 | statBytes[2] << 8 | statBytes[3];
|
||||||
@@ -510,14 +560,12 @@ class RNode {
|
|||||||
|
|
||||||
async getRssiStat() {
|
async getRssiStat() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_STAT_RSSI, [
|
||||||
this.CMD_STAT_RSSI,
|
|
||||||
0x00,
|
0x00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, rssi ] = await this.readFromSerialPort();
|
const [ rssi ] = response;
|
||||||
|
|
||||||
return rssi;
|
return rssi;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -536,7 +584,23 @@ class RNode {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async startBluetoothPairing() {
|
async startBluetoothPairing(pinCallback) {
|
||||||
|
|
||||||
|
// listen for bluetooth pin
|
||||||
|
// pin will be available once the user has initiated pairing from an Android device
|
||||||
|
this.callbacks[this.CMD_BT_PIN] = (response) => {
|
||||||
|
|
||||||
|
// read response from device
|
||||||
|
const [ ...pinBytes ] = response;
|
||||||
|
|
||||||
|
// convert 4 bytes to 32bit integer
|
||||||
|
const pin = pinBytes[0] << 24 | pinBytes[1] << 16 | pinBytes[2] << 8 | pinBytes[3];
|
||||||
|
|
||||||
|
// tell user what the bluetooth pin is
|
||||||
|
console.log("Bluetooth Pairing Pin: " + pin);
|
||||||
|
pinCallback(pin);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
// enable pairing
|
// enable pairing
|
||||||
await this.sendKissCommand([
|
await this.sendKissCommand([
|
||||||
@@ -544,43 +608,16 @@ class RNode {
|
|||||||
0x02, // enable pairing
|
0x02, // enable pairing
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// todo: listen for packets, pin will be available once user has initiated pairing from Android device
|
|
||||||
|
|
||||||
// // attempt to get bluetooth pairing pin
|
|
||||||
// try {
|
|
||||||
//
|
|
||||||
// // read response from device
|
|
||||||
// const [ command, ...pinBytes ] = await this.readFromSerialPort(5000);
|
|
||||||
// if(command !== this.CMD_BT_PIN){
|
|
||||||
// throw `unexpected command response: ${command}`;
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // convert 4 bytes to 32bit integer
|
|
||||||
// const pin = pinBytes[0] << 24 | pinBytes[1] << 16 | pinBytes[2] << 8 | pinBytes[3];
|
|
||||||
//
|
|
||||||
// // todo: remove logs
|
|
||||||
// console.log(pinBytes);
|
|
||||||
// console.log(pin);
|
|
||||||
//
|
|
||||||
// // todo: convert to string
|
|
||||||
// return pin;
|
|
||||||
//
|
|
||||||
// } catch(error) {
|
|
||||||
// throw `failed to get bluetooth pin: ${error}`;
|
|
||||||
// }
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async readDisplay() {
|
async readDisplay() {
|
||||||
|
|
||||||
await this.sendKissCommand([
|
const response = await this.sendCommand(this.CMD_DISP_READ, [
|
||||||
this.CMD_DISP_READ,
|
|
||||||
0x01,
|
0x01,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// read response from device
|
// read response from device
|
||||||
const [ command, ...displayBuffer ] = await this.readFromSerialPort();
|
const [ ...displayBuffer ] = response;
|
||||||
|
|
||||||
return displayBuffer;
|
return displayBuffer;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -715,6 +752,20 @@ class RNode {
|
|||||||
return new ROM(rom);
|
return new ROM(rom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setDisplayRotation(rotation) {
|
||||||
|
await this.sendKissCommand([
|
||||||
|
this.CMD_DISP_ROT,
|
||||||
|
rotation & 0xFF,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startDisplayReconditioning() {
|
||||||
|
await this.sendKissCommand([
|
||||||
|
this.CMD_DISP_RCND,
|
||||||
|
0x01,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ROM {
|
class ROM {
|
||||||
@@ -743,6 +794,7 @@ class ROM {
|
|||||||
static MODEL_A7 = 0xA7
|
static MODEL_A7 = 0xA7
|
||||||
static MODEL_A5 = 0xA5;
|
static MODEL_A5 = 0xA5;
|
||||||
static MODEL_AA = 0xAA;
|
static MODEL_AA = 0xAA;
|
||||||
|
static MODEL_AC = 0xAC;
|
||||||
|
|
||||||
static PRODUCT_T32_10 = 0xB2
|
static PRODUCT_T32_10 = 0xB2
|
||||||
static MODEL_BA = 0xBA
|
static MODEL_BA = 0xBA
|
||||||
@@ -766,6 +818,10 @@ class ROM {
|
|||||||
static MODEL_C5 = 0xC5
|
static MODEL_C5 = 0xC5
|
||||||
static MODEL_CA = 0xCA
|
static MODEL_CA = 0xCA
|
||||||
|
|
||||||
|
static PRODUCT_HELTEC_T114 = 0xC2
|
||||||
|
static MODEL_C6 = 0xC6
|
||||||
|
static MODEL_C7 = 0xC7
|
||||||
|
|
||||||
static PRODUCT_TBEAM = 0xE0
|
static PRODUCT_TBEAM = 0xE0
|
||||||
static MODEL_E4 = 0xE4
|
static MODEL_E4 = 0xE4
|
||||||
static MODEL_E9 = 0xE9
|
static MODEL_E9 = 0xE9
|
||||||
@@ -781,8 +837,8 @@ class ROM {
|
|||||||
static MODEL_D9 = 0xD9;
|
static MODEL_D9 = 0xD9;
|
||||||
|
|
||||||
static PRODUCT_TECHO = 0x15;
|
static PRODUCT_TECHO = 0x15;
|
||||||
static MODEL_T4 = 0x16;
|
static MODEL_16 = 0x16;
|
||||||
static MODEL_T9 = 0x17;
|
static MODEL_17 = 0x17;
|
||||||
|
|
||||||
static PRODUCT_HMBRW = 0xF0
|
static PRODUCT_HMBRW = 0xF0
|
||||||
static MODEL_FF = 0xFF
|
static MODEL_FF = 0xFF
|
||||||
|
|||||||
@@ -1,3 +1,81 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #94a3b8 #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #94a3b8;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark * {
|
||||||
|
scrollbar-color: #52525b #18181b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark *::-webkit-scrollbar-track {
|
||||||
|
background: #18181b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark *::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #52525b;
|
||||||
|
border-color: #18181b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 px-4 py-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 dark:hover:border-blue-500 transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-label {
|
||||||
|
@apply mb-1 text-sm font-semibold text-gray-800 dark:text-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monospace-field {
|
||||||
|
@apply font-mono tracking-tight text-gray-900 dark:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row {
|
||||||
|
@apply grid gap-3 sm:grid-cols-2 text-gray-800 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
@apply text-lg font-semibold text-gray-900 dark:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,52 @@ export default {
|
|||||||
call: path.join(__dirname, "src", "frontend", "call.html"),
|
call: path.join(__dirname, "src", "frontend", "call.html"),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (id.includes('node_modules')) {
|
||||||
|
if (id.includes('vuetify')) {
|
||||||
|
return 'vendor-vuetify';
|
||||||
|
}
|
||||||
|
if (id.includes('vis-network') || id.includes('vis-data')) {
|
||||||
|
return 'vendor-vis';
|
||||||
|
}
|
||||||
|
if (id.includes('vue-router')) {
|
||||||
|
return 'vendor-vue-router';
|
||||||
|
}
|
||||||
|
if (id.includes('vue')) {
|
||||||
|
return 'vendor-vue';
|
||||||
|
}
|
||||||
|
if (id.includes('protobufjs') || id.includes('@protobufjs')) {
|
||||||
|
return 'vendor-protobuf';
|
||||||
|
}
|
||||||
|
if (id.includes('moment')) {
|
||||||
|
return 'vendor-moment';
|
||||||
|
}
|
||||||
|
if (id.includes('axios')) {
|
||||||
|
return 'vendor-axios';
|
||||||
|
}
|
||||||
|
if (id.includes('@mdi/js')) {
|
||||||
|
return 'vendor-mdi';
|
||||||
|
}
|
||||||
|
if (id.includes('compressorjs')) {
|
||||||
|
return 'vendor-compressor';
|
||||||
|
}
|
||||||
|
if (id.includes('click-outside-vue3')) {
|
||||||
|
return 'vendor-click-outside';
|
||||||
|
}
|
||||||
|
if (id.includes('mitt')) {
|
||||||
|
return 'vendor-mitt';
|
||||||
|
}
|
||||||
|
if (id.includes('micron-parser')) {
|
||||||
|
return 'vendor-micron';
|
||||||
|
}
|
||||||
|
if (id.includes('electron-prompt')) {
|
||||||
|
return 'vendor-electron-prompt';
|
||||||
|
}
|
||||||
|
return 'vendor-other';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user