Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ba47e16b75
|
|||
|
578e80023f
|
|||
|
b7dcee4c06
|
|||
|
e44ec59b6e
|
|||
|
45379e6df1
|
|||
|
308f1f6459
|
|||
| 424ff116d1 | |||
|
|
73f677d319 | ||
|
4770c21499
|
|||
|
720bef90c7
|
|||
|
1c98a231fd
|
|||
|
f6a1be5e80
|
|||
|
dea21b8515
|
|||
|
|
c14619e3e3 | ||
|
bc20f85cbf
|
|||
|
8ec7acd57e
|
|||
|
59e76de4cc
|
|||
|
cc30e6abc1
|
|||
|
6dffe70e9b
|
|||
|
c054d16f08
|
|||
|
1dbd9a5697
|
|||
|
65d6656f47
|
|||
|
a3cb84fa06
|
|||
|
5fcc86d65a
|
|||
|
253872eb57
|
|||
|
609a7ede6c
|
|||
|
49b9bd7782
|
|||
|
c83b90f4f8
|
|||
|
d48a6d9620
|
|||
|
ef80e7a7c4
|
|||
|
5967cd827f
|
|||
|
52558a7167
|
|||
|
24194d7e98
|
|||
|
273ecfbbbf
|
|||
|
c65cb04da9
|
|||
|
919d191e61
|
|||
|
5374a62e96
|
|||
|
2e0dfe8700
|
|||
|
2f30be1490
|
|||
|
79aa2bbaa5
|
|||
|
c92a86015c
|
|||
|
432845195d
|
|||
|
50a1bf6b3f
|
|||
|
8c39fdf190
|
|||
|
9e352e5058
|
|||
|
0543bd6044
|
|||
|
17d25b2e0a
|
|||
|
1a4b99b201
|
|||
|
9115f6ecfa
|
|||
|
918fcb051c
|
|||
|
acbe3597d6
|
|||
|
3566c6b2da
|
|||
|
8b044f6dab
|
|||
|
4c4b963aef
|
|||
|
38ac972960
|
|||
|
becd3aa15d
|
|||
|
ac907308c0
|
|||
|
fa2fe6a15d
|
|||
|
927255f44c
|
|||
|
0318cb7e4a
|
|||
|
442ac41841
|
|||
|
40f286621d
|
|||
| 9944e9bd63 | |||
|
|
ec0b5a0924 | ||
| bae4e96d2a | |||
| fa15b8f7a3 | |||
| 2ee27557bd | |||
| 8b82a66315 | |||
| 72b0f95cf5 | |||
| 1f8ec5aa2f | |||
|
|
6827ae9c84 | ||
| 95ef0935da | |||
| 5a5d4b9283 | |||
| 51eaa83301 | |||
| b034b937cd | |||
| 69d8bab9e4 | |||
| adac0e5bb1 | |||
| 12313d34ee | |||
| 55126eaf82 | |||
| aa774f3511 | |||
| e0e2bbf091 | |||
| 61ada872c0 | |||
|
|
3260bffd60 | ||
|
|
bbc1eec48e | ||
|
|
72266680a2 | ||
|
|
f0336873db | ||
|
|
d9a39f1ea9 | ||
| f0edb4bc8d | |||
| e9d45f257e | |||
| 00e0461a16 | |||
| c56b982df5 |
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:
|
||||
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:
|
||||
build_windows:
|
||||
runs-on: windows-latest
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_windows == 'true')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v1
|
||||
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||
|
||||
- name: Install NodeJS
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
|
||||
- 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
|
||||
run: npm install
|
||||
@@ -35,7 +61,7 @@ jobs:
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: ncipollo/release-action@v1
|
||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||
with:
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
@@ -46,24 +72,28 @@ jobs:
|
||||
|
||||
build_mac:
|
||||
runs-on: macos-13
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_mac == 'true')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v1
|
||||
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||
|
||||
- name: Install NodeJS
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- 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
|
||||
run: npm install
|
||||
@@ -73,7 +103,7 @@ jobs:
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: ncipollo/release-action@v1
|
||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||
with:
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
@@ -84,24 +114,31 @@ jobs:
|
||||
|
||||
build_linux:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_linux == 'true')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@v1
|
||||
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
|
||||
|
||||
- name: Install NodeJS
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
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
|
||||
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
|
||||
run: npm install
|
||||
@@ -111,47 +148,51 @@ jobs:
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: ncipollo/release-action@v1
|
||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
|
||||
with:
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
replacesArtifacts: true
|
||||
omitDraftDuringUpdate: true
|
||||
omitNameDuringUpdate: true
|
||||
artifacts: "dist/*-linux.AppImage"
|
||||
artifacts: "dist/*-linux.AppImage,dist/*-linux.deb"
|
||||
|
||||
build_docker:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_docker == 'true')
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: actions/checkout@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
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
|
||||
|
||||
- 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
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # 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/
|
||||
tags: >-
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:latest,
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:${{ github.ref_name }}
|
||||
labels: >-
|
||||
org.opencontainers.image.title=Reticulum MeshChatX,
|
||||
org.opencontainers.image.description=Docker image for Reticulum MeshChatX,
|
||||
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchatx/
|
||||
|
||||
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
|
||||
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
|
||||
FROM node:20-bookworm-slim AS build-frontend
|
||||
FROM node:${NODE_VERSION}-alpine@${NODE_ALPINE_SHA256} AS build-frontend
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -13,13 +19,19 @@ RUN npm install --omit=dev && \
|
||||
npm run build-frontend
|
||||
|
||||
# Main app build
|
||||
FROM python:3.11-bookworm
|
||||
FROM python:${PYTHON_VERSION}-alpine@${PYTHON_ALPINE_SHA256}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python deps
|
||||
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 --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
|
||||
|
||||
|
||||
|
||||
311
README.md
311
README.md
@@ -1,297 +1,46 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/liamcottle/reticulum-meshchat"><img src="./logo/logo-chat-bubble.png" width="150"></a>
|
||||
</p>
|
||||
# Reticulum MeshChatX
|
||||
|
||||
<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">
|
||||
<a href="https://discord.gg/APQSQZNV7t"><img src="https://img.shields.io/badge/Discord-Liam%20Cottle's%20Discord-%237289DA?style=flat&logo=discord" alt="discord"/></a>
|
||||
<a href="https://twitter.com/liamcottle"><img src="https://img.shields.io/badge/Twitter-@liamcottle-%231DA1F2?style=flat&logo=twitter" alt="twitter"/></a>
|
||||
<br/>
|
||||
<a href="https://ko-fi.com/liamcottle"><img src="https://img.shields.io/badge/Donate%20a%20Coffee-liamcottle-yellow?style=flat&logo=buy-me-a-coffee" alt="donate on ko-fi"/></a>
|
||||
<a href="./donate.md"><img src="https://img.shields.io/badge/Donate%20Bitcoin-bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q-%23FF9900?style=flat&logo=bitcoin" alt="donate bitcoin"/></a>
|
||||
</p>
|
||||
## Features of this fork
|
||||
|
||||
## 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] Numerous Ruff, Deepsource, CodeQL Advanced and Bearer Linting/SAST fixes.
|
||||
- [x] Some performance improvements.
|
||||
|
||||
A simple mesh network communications app powered by the [Reticulum Network Stack](https://github.com/markqvist/Reticulum).
|
||||
## Usage
|
||||
|
||||
<img src="./screenshots/screenshot.png">
|
||||
Check [releases](https://github.com/Sudo-Ivan/reticulum-meshchatX/releases) for pre-built binaries or appimages.
|
||||
|
||||
## What does it do?
|
||||
## Building
|
||||
|
||||
- It can send and receive messages, files and audio calls with peers;
|
||||
- Over your local network through Ethernet and WiFi, completely automatically.
|
||||
- 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).
|
||||
- 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
|
||||
```bash
|
||||
make install
|
||||
make build
|
||||
```
|
||||
|
||||
> 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;
|
||||
|
||||
```
|
||||
python meshchat.py --help
|
||||
```bash
|
||||
make docker-build
|
||||
```
|
||||
|
||||
```
|
||||
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]
|
||||
The build will be in the `dist` directory.
|
||||
|
||||
ReticulumMeshChat
|
||||
## Development
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
--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)
|
||||
```bash
|
||||
make develop
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
# 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
|
||||
|
||||
214
database.py
214
database.py
@@ -1,60 +1,73 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from peewee import *
|
||||
from playhouse.migrate import migrate as migrate_database, SqliteMigrator
|
||||
from peewee import * # noqa: F403
|
||||
from playhouse.migrate import SqliteMigrator
|
||||
from playhouse.migrate import migrate as migrate_database
|
||||
|
||||
latest_version = 5 # increment each time new database migrations are added
|
||||
database = DatabaseProxy() # use a proxy object, as we will init real db client inside meshchat.py
|
||||
latest_version = 6 # increment each time new database migrations are added
|
||||
database = (
|
||||
DatabaseProxy() # noqa: F405
|
||||
) # use a proxy object, as we will init real db client inside meshchat.py
|
||||
migrator = SqliteMigrator(database)
|
||||
|
||||
|
||||
# migrates the database
|
||||
def migrate(current_version):
|
||||
|
||||
# migrate to version 2
|
||||
if current_version < 2:
|
||||
migrate_database(
|
||||
migrator.add_column("lxmf_messages", 'delivery_attempts', LxmfMessage.delivery_attempts),
|
||||
migrator.add_column("lxmf_messages", 'next_delivery_attempt_at', LxmfMessage.next_delivery_attempt_at),
|
||||
migrator.add_column(
|
||||
"lxmf_messages", "delivery_attempts", LxmfMessage.delivery_attempts,
|
||||
),
|
||||
migrator.add_column(
|
||||
"lxmf_messages",
|
||||
"next_delivery_attempt_at",
|
||||
LxmfMessage.next_delivery_attempt_at,
|
||||
),
|
||||
)
|
||||
|
||||
# migrate to version 3
|
||||
if current_version < 3:
|
||||
migrate_database(
|
||||
migrator.add_column("lxmf_messages", 'rssi', LxmfMessage.rssi),
|
||||
migrator.add_column("lxmf_messages", 'snr', LxmfMessage.snr),
|
||||
migrator.add_column("lxmf_messages", 'quality', LxmfMessage.quality),
|
||||
migrator.add_column("lxmf_messages", "rssi", LxmfMessage.rssi),
|
||||
migrator.add_column("lxmf_messages", "snr", LxmfMessage.snr),
|
||||
migrator.add_column("lxmf_messages", "quality", LxmfMessage.quality),
|
||||
)
|
||||
|
||||
# migrate to version 4
|
||||
if current_version < 4:
|
||||
migrate_database(
|
||||
migrator.add_column("lxmf_messages", 'method', LxmfMessage.method),
|
||||
migrator.add_column("lxmf_messages", "method", LxmfMessage.method),
|
||||
)
|
||||
|
||||
# migrate to version 5
|
||||
if current_version < 5:
|
||||
migrate_database(
|
||||
migrator.add_column("announces", 'rssi', Announce.rssi),
|
||||
migrator.add_column("announces", 'snr', Announce.snr),
|
||||
migrator.add_column("announces", 'quality', Announce.quality),
|
||||
migrator.add_column("announces", "rssi", Announce.rssi),
|
||||
migrator.add_column("announces", "snr", Announce.snr),
|
||||
migrator.add_column("announces", "quality", Announce.quality),
|
||||
)
|
||||
|
||||
# migrate to version 6
|
||||
if current_version < 6:
|
||||
migrate_database(
|
||||
migrator.add_column("lxmf_messages", "is_spam", LxmfMessage.is_spam),
|
||||
)
|
||||
|
||||
return latest_version
|
||||
|
||||
|
||||
class BaseModel(Model):
|
||||
class BaseModel(Model): # noqa: F405
|
||||
class Meta:
|
||||
database = database
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
|
||||
id = BigAutoField()
|
||||
key = CharField(unique=True)
|
||||
value = TextField()
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
id = BigAutoField() # noqa: F405
|
||||
key = CharField(unique=True) # noqa: F405
|
||||
value = TextField() # noqa: F405
|
||||
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:
|
||||
@@ -62,19 +75,26 @@ class Config(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()
|
||||
destination_hash = CharField(unique=True) # unique destination hash that was announced
|
||||
aspect = TextField(index=True) # aspect is not included in announce, but we want to filter saved announces by aspect
|
||||
identity_hash = CharField(index=True) # identity hash that announced the destination
|
||||
identity_public_key = CharField() # base64 encoded public key, incase we want to recreate the identity manually
|
||||
app_data = TextField(null=True) # base64 encoded app data bytes
|
||||
rssi = IntegerField(null=True)
|
||||
snr = FloatField(null=True)
|
||||
quality = FloatField(null=True)
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
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:
|
||||
@@ -82,13 +102,12 @@ class Announce(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()
|
||||
destination_hash = CharField(unique=True) # unique destination hash
|
||||
display_name = CharField() # custom display name for the destination hash
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
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:
|
||||
@@ -96,14 +115,13 @@ class CustomDestinationDisplayName(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()
|
||||
destination_hash = CharField(unique=True) # unique destination hash
|
||||
display_name = CharField() # custom display name for the destination hash
|
||||
aspect = CharField() # e.g: nomadnetwork.node
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
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:
|
||||
@@ -111,26 +129,36 @@ class FavouriteDestination(BaseModel):
|
||||
|
||||
|
||||
class LxmfMessage(BaseModel):
|
||||
|
||||
id = BigAutoField()
|
||||
hash = CharField(unique=True) # unique lxmf message hash
|
||||
source_hash = CharField(index=True)
|
||||
destination_hash = CharField(index=True)
|
||||
state = CharField() # state is converted from internal int to a human friendly string
|
||||
progress = FloatField() # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
|
||||
is_incoming = BooleanField() # if true, we should ignore state, it's set to draft by default on incoming messages
|
||||
method = CharField(null=True) # what method is being used to send the message, e.g: direct, propagated
|
||||
delivery_attempts = IntegerField(default=0) # how many times delivery has been attempted for this message
|
||||
next_delivery_attempt_at = FloatField(null=True) # timestamp of when the message will attempt delivery again
|
||||
title = TextField()
|
||||
content = TextField()
|
||||
fields = TextField() # json string
|
||||
timestamp = FloatField() # timestamp of when the message was originally created (before ever being sent)
|
||||
rssi = IntegerField(null=True)
|
||||
snr = FloatField(null=True)
|
||||
quality = FloatField(null=True)
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
id = BigAutoField() # noqa: F405
|
||||
hash = CharField(unique=True) # noqa: F405 # unique lxmf message hash
|
||||
source_hash = CharField(index=True) # noqa: F405
|
||||
destination_hash = CharField(index=True) # noqa: F405
|
||||
state = ( # noqa: F405
|
||||
CharField() # noqa: F405
|
||||
) # state is converted from internal int to a human friendly string
|
||||
progress = FloatField() # noqa: F405 # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
|
||||
is_incoming = BooleanField() # noqa: F405 # if true, we should ignore state, it's set to draft by default on incoming messages
|
||||
method = CharField( # noqa: F405
|
||||
null=True,
|
||||
) # what method is being used to send the message, e.g: direct, propagated
|
||||
delivery_attempts = IntegerField( # noqa: F405
|
||||
default=0,
|
||||
) # how many times delivery has been attempted for this message
|
||||
next_delivery_attempt_at = FloatField( # noqa: F405
|
||||
null=True,
|
||||
) # timestamp of when the message will attempt delivery again
|
||||
title = TextField() # noqa: F405
|
||||
content = TextField() # noqa: F405
|
||||
fields = TextField() # noqa: F405 # json string
|
||||
timestamp = ( # 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
|
||||
class Meta:
|
||||
@@ -138,13 +166,12 @@ class LxmfMessage(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()
|
||||
destination_hash = CharField(unique=True) # unique destination hash
|
||||
last_read_at = DateTimeField()
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
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:
|
||||
@@ -152,16 +179,43 @@ class LxmfConversationReadState(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()
|
||||
destination_hash = CharField(unique=True) # unique destination hash
|
||||
icon_name = CharField() # material design icon name for the destination hash
|
||||
foreground_colour = CharField() # hex colour to use for foreground (icon colour)
|
||||
background_colour = CharField() # hex colour to use for background (background colour)
|
||||
|
||||
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
|
||||
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 = "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:
|
||||
reticulum-meshchat:
|
||||
container_name: reticulum-meshchat
|
||||
image: ghcr.io/liamcottle/reticulum-meshchat:latest
|
||||
reticulum-meshchatx:
|
||||
container_name: reticulum-meshchatx
|
||||
image: ghcr.io/sudo-ivan/reticulum-meshchatx:latest
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
# Make the meshchat web interface accessible from the host on port 8000
|
||||
ports:
|
||||
- 0.0.0.0:8000:8000
|
||||
- 127.0.0.1:8000:8000
|
||||
volumes:
|
||||
- meshchat-config:/config
|
||||
# Uncomment if you have a USB device connected, such as an RNode
|
||||
|
||||
10
donate.md
10
donate.md
@@ -1,10 +0,0 @@
|
||||
# Donate
|
||||
|
||||
Thank you for considering donating, this helps support my work on this project 😁
|
||||
|
||||
## How can I donate?
|
||||
|
||||
- Bitcoin: bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q
|
||||
- Ethereum: 0xc64CFbA5D0BF7664158c5671F64d446395b3bF3D
|
||||
- Buy me a Coffee: [https://ko-fi.com/liamcottle](https://ko-fi.com/liamcottle)
|
||||
- Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle)
|
||||
@@ -179,13 +179,53 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
// find path to python/cxfreeze reticulum meshchat executable
|
||||
const exeName = process.platform === "win32" ? "ReticulumMeshChat.exe" : "ReticulumMeshChat";
|
||||
var exe = path.join(__dirname, `build/exe/${exeName}`);
|
||||
|
||||
// if dist exe doesn't exist, check local build
|
||||
if(!fs.existsSync(exe)){
|
||||
exe = path.join(__dirname, '..', `build/exe/${exeName}`);
|
||||
// Note: setup.py creates ReticulumMeshChatX (with X), not ReticulumMeshChat
|
||||
const exeName = process.platform === "win32" ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX";
|
||||
|
||||
// get app path (handles both development and packaged app)
|
||||
const appPath = app.getAppPath();
|
||||
// get resources path (where extraFiles are placed)
|
||||
const resourcesPath = process.resourcesPath || path.join(appPath, '..', '..');
|
||||
var exe = null;
|
||||
|
||||
// when packaged, extraFiles are placed at resources/app/electron/build/exe
|
||||
// when packaged with asar, unpacked files are in app.asar.unpacked/ directory
|
||||
// app.getAppPath() returns the path to app.asar, so unpacked is at the same level
|
||||
const possiblePaths = [
|
||||
// packaged app - extraFiles location (resources/app/electron/build/exe)
|
||||
path.join(resourcesPath, 'app', 'electron', 'build', 'exe', exeName),
|
||||
// packaged app with asar (unpacked files from asarUnpack)
|
||||
path.join(appPath, '..', 'app.asar.unpacked', 'build', 'exe', exeName),
|
||||
// packaged app without asar (relative to app path)
|
||||
path.join(appPath, 'build', 'exe', exeName),
|
||||
// development mode (relative to electron directory)
|
||||
path.join(__dirname, 'build', 'exe', exeName),
|
||||
// development mode (relative to project root)
|
||||
path.join(__dirname, '..', 'build', 'exe', exeName),
|
||||
];
|
||||
|
||||
// find the first path that exists
|
||||
for(const possibleExe of possiblePaths){
|
||||
if(fs.existsSync(possibleExe)){
|
||||
exe = possibleExe;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// verify executable exists
|
||||
if(!exe || !fs.existsSync(exe)){
|
||||
const errorMsg = `Could not find executable: ${exeName}\nChecked paths:\n${possiblePaths.join('\n')}\n\nApp path: ${appPath}\nResources path: ${resourcesPath}`;
|
||||
log(errorMsg);
|
||||
if(mainWindow){
|
||||
await dialog.showMessageBox(mainWindow, {
|
||||
message: errorMsg,
|
||||
});
|
||||
}
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Found executable at: ${exe}`);
|
||||
|
||||
try {
|
||||
|
||||
|
||||
3666
meshchat.py
3666
meshchat.py
File diff suppressed because it is too large
Load Diff
2333
package-lock.json
generated
2333
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "reticulum-meshchat",
|
||||
"version": "2.2.1",
|
||||
"description": "",
|
||||
"name": "reticulum-meshchatx",
|
||||
"version": "2.41.0",
|
||||
"description": "A simple mesh network communications app powered by the Reticulum Network Stack",
|
||||
"author": "Sudo-Ivan",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
"watch": "npm run build-frontend -- --watch",
|
||||
"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",
|
||||
"electron-postinstall": "electron-builder install-app-deps",
|
||||
"electron": "npm run electron-postinstall && npm run build && electron .",
|
||||
@@ -17,13 +18,16 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^30.0.8",
|
||||
"electron": "^39.2.4",
|
||||
"electron-builder": "^24.6.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.liamcottle.reticulummeshchat",
|
||||
"productName": "Reticulum MeshChat",
|
||||
"asar": false,
|
||||
"appId": "com.sudoivan.reticulummeshchat",
|
||||
"productName": "Reticulum MeshChatX",
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"build/exe/**/*"
|
||||
],
|
||||
"files": [
|
||||
"electron/**/*"
|
||||
],
|
||||
@@ -70,7 +74,12 @@
|
||||
},
|
||||
"linux": {
|
||||
"artifactName": "ReticulumMeshChat-v${version}-${os}.${ext}",
|
||||
"target": "AppImage",
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"maintainer": "Sudo-Ivan",
|
||||
"category": "Network",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "build/exe",
|
||||
@@ -98,7 +107,7 @@
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.10.0",
|
||||
"axios": "^1.12.0",
|
||||
"click-outside-vue3": "^4.0.1",
|
||||
"compressorjs": "^1.2.1",
|
||||
"electron-prompt": "^1.7.0",
|
||||
@@ -110,7 +119,7 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vis-data": "^7.1.9",
|
||||
"vis-network": "^9.1.9",
|
||||
"vite": "^6.0.5",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-vuetify": "^2.0.4",
|
||||
"vue-router": "^4.5.0",
|
||||
"vuetify": "^3.7.6"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
aiohttp>=3.12.14
|
||||
cx_freeze>=7.0.0
|
||||
lxmf>=0.8.0
|
||||
lxmf>=0.9.3
|
||||
peewee>=3.18.1
|
||||
rns>=1.0.0
|
||||
psutil>=7.1.3
|
||||
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(
|
||||
name='ReticulumMeshChat',
|
||||
version='1.0.0',
|
||||
description='A simple mesh network communications app powered by the Reticulum Network Stack',
|
||||
name="ReticulumMeshChatX",
|
||||
version="1.0.0",
|
||||
description="A simple mesh network communications app powered by the Reticulum Network Stack",
|
||||
executables=[
|
||||
Executable(
|
||||
script='meshchat.py', # this script to run
|
||||
base=None, # we are running a console application, not a gui
|
||||
target_name='ReticulumMeshChat', # creates ReticulumMeshChat.exe
|
||||
shortcut_name='ReticulumMeshChat', # name shown in shortcut
|
||||
shortcut_dir='ProgramMenuFolder', # put the shortcut in windows start menu
|
||||
icon='logo/icon.ico', # set the icon for the exe
|
||||
copyright='Copyright (c) 2024 Liam Cottle',
|
||||
script="meshchat.py", # this script to run
|
||||
base=None, # we are running a console application, not a gui
|
||||
target_name="ReticulumMeshChatX", # creates ReticulumMeshChatX.exe
|
||||
shortcut_name="ReticulumMeshChatX", # name shown in shortcut
|
||||
shortcut_dir="ProgramMenuFolder", # put the shortcut in windows start menu
|
||||
icon="logo/icon.ico", # set the icon for the exe
|
||||
copyright="Copyright (c) 2024 Liam Cottle",
|
||||
),
|
||||
],
|
||||
options={
|
||||
'build_exe': {
|
||||
"build_exe": {
|
||||
# libs that are required
|
||||
'packages': [
|
||||
"packages": [
|
||||
# required for dynamic import fix
|
||||
# https://github.com/marcelotduarte/cx_Freeze/discussions/2039
|
||||
# https://github.com/marcelotduarte/cx_Freeze/issues/2041
|
||||
'RNS',
|
||||
"RNS",
|
||||
"RNS.Interfaces",
|
||||
"LXMF",
|
||||
],
|
||||
# files that are required
|
||||
'include_files': [
|
||||
'package.json', # used to determine app version from python
|
||||
'public/', # static files served by web server
|
||||
"include_files": [
|
||||
"package.json", # used to determine app version from python
|
||||
"public/", # static files served by web server
|
||||
],
|
||||
# slim down the build by excluding these unused libs
|
||||
'excludes': [
|
||||
'PIL', # saves ~200MB
|
||||
"excludes": [
|
||||
"PIL", # saves ~200MB
|
||||
],
|
||||
# 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.
|
||||
# https://stackoverflow.com/a/57948104
|
||||
"optimize": 2,
|
||||
# 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
|
||||
class ImmediateFlushingStreamWrapper:
|
||||
|
||||
def __init__(self, stream):
|
||||
self.stream = stream
|
||||
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
# an announce handler that forwards announces to a provided callback for the provided aspect filter
|
||||
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
|
||||
class AnnounceHandler:
|
||||
|
||||
def __init__(self, aspect_filter: str, received_announce_callback):
|
||||
self.aspect_filter = aspect_filter
|
||||
self.received_announce_callback = received_announce_callback
|
||||
|
||||
# we will just pass the received announce back to the provided callback
|
||||
def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash):
|
||||
def received_announce(
|
||||
self, destination_hash, announced_identity, app_data, announce_packet_hash,
|
||||
):
|
||||
try:
|
||||
# handle received announce
|
||||
self.received_announce_callback(self.aspect_filter, destination_hash, announced_identity, app_data, announce_packet_hash)
|
||||
except:
|
||||
self.received_announce_callback(
|
||||
self.aspect_filter,
|
||||
destination_hash,
|
||||
announced_identity,
|
||||
app_data,
|
||||
announce_packet_hash,
|
||||
)
|
||||
except Exception: # noqa: E722
|
||||
# ignore failure to handle received announce
|
||||
pass
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import asyncio
|
||||
from typing import Coroutine
|
||||
from collections.abc import Coroutine
|
||||
|
||||
|
||||
class AsyncUtils:
|
||||
|
||||
# remember main loop
|
||||
main_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
@@ -15,7 +14,6 @@ class AsyncUtils:
|
||||
# it will run the async function on the main event loop if possible, otherwise it logs a warning
|
||||
@staticmethod
|
||||
def run_async(coroutine: Coroutine):
|
||||
|
||||
# run provided coroutine on main event loop, ensuring thread safety
|
||||
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import RNS
|
||||
|
||||
# todo optionally identity self over link
|
||||
# todo allowlist/denylist for incoming calls
|
||||
# TODO optionally identity self over link
|
||||
# TODO allowlist/denylist for incoming calls
|
||||
|
||||
|
||||
class CallFailedException(Exception):
|
||||
@@ -13,7 +12,6 @@ class CallFailedException(Exception):
|
||||
|
||||
|
||||
class AudioCall:
|
||||
|
||||
def __init__(self, link: RNS.Link, is_outbound: bool):
|
||||
self.link = link
|
||||
self.is_outbound = is_outbound
|
||||
@@ -41,21 +39,25 @@ class AudioCall:
|
||||
|
||||
# handle packet received over link
|
||||
def on_packet(self, message, packet):
|
||||
|
||||
# send audio received from call initiator to all audio packet listeners
|
||||
for audio_packet_listener in self.audio_packet_listeners:
|
||||
audio_packet_listener(message)
|
||||
|
||||
# send an audio packet over the link
|
||||
def send_audio_packet(self, data):
|
||||
|
||||
# do nothing if link is not active
|
||||
if self.is_active() is False:
|
||||
return
|
||||
|
||||
# drop audio packet if it is too big to send
|
||||
if len(data) > RNS.Link.MDU:
|
||||
print("[AudioCall] dropping audio packet " + str(len(data)) + " bytes exceeds the link packet MDU of " + str(RNS.Link.MDU) + " bytes")
|
||||
print(
|
||||
"[AudioCall] dropping audio packet "
|
||||
+ str(len(data))
|
||||
+ " bytes exceeds the link packet MDU of "
|
||||
+ str(RNS.Link.MDU)
|
||||
+ " bytes",
|
||||
)
|
||||
return
|
||||
|
||||
# send codec2 audio received from call receiver to call initiator over reticulum link
|
||||
@@ -73,25 +75,26 @@ class AudioCall:
|
||||
def hangup(self):
|
||||
print("[AudioCall] hangup")
|
||||
self.link.teardown()
|
||||
pass
|
||||
|
||||
|
||||
class AudioCallManager:
|
||||
|
||||
def __init__(self, identity: RNS.Identity):
|
||||
|
||||
def __init__(self, identity: RNS.Identity, is_destination_blocked_callback=None):
|
||||
self.identity = identity
|
||||
self.on_incoming_call_callback = None
|
||||
self.on_outgoing_call_callback = None
|
||||
self.is_destination_blocked_callback = is_destination_blocked_callback
|
||||
self.audio_call_receiver = AudioCallReceiver(manager=self)
|
||||
|
||||
# remember audio calls
|
||||
self.audio_calls: List[AudioCall] = []
|
||||
self.audio_calls: list[AudioCall] = []
|
||||
|
||||
# announces the audio call destination
|
||||
def announce(self, app_data=None):
|
||||
self.audio_call_receiver.destination.announce(app_data)
|
||||
print("[AudioCallManager] announced destination: " + RNS.prettyhexrep(self.audio_call_receiver.destination.hash))
|
||||
print(
|
||||
"[AudioCallManager] announced destination: "
|
||||
+ RNS.prettyhexrep(self.audio_call_receiver.destination.hash),
|
||||
)
|
||||
|
||||
# set the callback for incoming calls
|
||||
def register_incoming_call_callback(self, callback):
|
||||
@@ -103,7 +106,6 @@ class AudioCallManager:
|
||||
|
||||
# handle incoming calls from audio call receiver
|
||||
def handle_incoming_call(self, audio_call: AudioCall):
|
||||
|
||||
# remember it
|
||||
self.audio_calls.append(audio_call)
|
||||
|
||||
@@ -113,7 +115,6 @@ class AudioCallManager:
|
||||
|
||||
# handle outgoing calls
|
||||
def handle_outgoing_call(self, audio_call: AudioCall):
|
||||
|
||||
# remember it
|
||||
self.audio_calls.append(audio_call)
|
||||
|
||||
@@ -142,22 +143,24 @@ class AudioCallManager:
|
||||
def hangup_all(self):
|
||||
for audio_call in self.audio_calls:
|
||||
audio_call.hangup()
|
||||
return None
|
||||
|
||||
# attempts to initiate a call to the provided destination and returns the link hash on success
|
||||
async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15) -> AudioCall:
|
||||
|
||||
async def initiate(
|
||||
self, destination_hash: bytes, timeout_seconds: int = 15,
|
||||
) -> AudioCall:
|
||||
# determine when to timeout
|
||||
timeout_after_seconds = time.time() + timeout_seconds
|
||||
|
||||
# check if we have a path to the destination
|
||||
if not RNS.Transport.has_path(destination_hash):
|
||||
|
||||
# we don't have a path, so we need to request it
|
||||
RNS.Transport.request_path(destination_hash)
|
||||
|
||||
# wait until we have a path, or give up after the configured timeout
|
||||
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after_seconds:
|
||||
while (
|
||||
not RNS.Transport.has_path(destination_hash)
|
||||
and time.time() < timeout_after_seconds
|
||||
):
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# if we still don't have a path, we can't establish a link, so bail out
|
||||
@@ -171,14 +174,16 @@ class AudioCallManager:
|
||||
RNS.Destination.OUT,
|
||||
RNS.Destination.SINGLE,
|
||||
"call",
|
||||
"audio"
|
||||
"audio",
|
||||
)
|
||||
|
||||
# create link
|
||||
link = RNS.Link(server_destination)
|
||||
|
||||
# wait until we have established a link, or give up after the configured timeout
|
||||
while link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds:
|
||||
while (
|
||||
link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds
|
||||
):
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# if we still haven't established a link, bail out
|
||||
@@ -191,16 +196,14 @@ class AudioCallManager:
|
||||
# handle new outgoing call
|
||||
self.handle_outgoing_call(audio_call)
|
||||
|
||||
# todo: this can be optional, it's only being sent by default for ui, can be removed
|
||||
# TODO: this can be optional, it's only being sent by default for ui, can be removed
|
||||
link.identify(self.identity)
|
||||
|
||||
return audio_call
|
||||
|
||||
|
||||
class AudioCallReceiver:
|
||||
|
||||
def __init__(self, manager: AudioCallManager):
|
||||
|
||||
self.manager = manager
|
||||
|
||||
# create destination for receiving audio calls
|
||||
@@ -224,8 +227,24 @@ class AudioCallReceiver:
|
||||
|
||||
# client connected to us, set up an audio call instance
|
||||
def client_connected(self, link: RNS.Link):
|
||||
# check if source is blocked
|
||||
if self.manager.is_destination_blocked_callback is not None:
|
||||
try:
|
||||
# try to get remote identity hash
|
||||
remote_identity = link.get_remote_identity()
|
||||
if remote_identity is not None:
|
||||
source_hash = remote_identity.hash.hex()
|
||||
if self.manager.is_destination_blocked_callback(source_hash):
|
||||
print(
|
||||
f"Rejecting audio call from blocked source: {source_hash}",
|
||||
)
|
||||
link.teardown()
|
||||
return
|
||||
except Exception: # 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)
|
||||
|
||||
# create audio call
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
class ColourUtils:
|
||||
|
||||
@staticmethod
|
||||
def hex_colour_to_byte_array(hex_colour):
|
||||
|
||||
# remove leading "#"
|
||||
hex_colour = hex_colour.lstrip('#')
|
||||
hex_colour = hex_colour.lstrip("#")
|
||||
|
||||
# convert the remaining hex string to bytes
|
||||
return bytes.fromhex(hex_colour)
|
||||
|
||||
@@ -2,30 +2,90 @@ 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 lines:
|
||||
if "[interfaces]" not in stripped_lines:
|
||||
lines.insert(0, "[interfaces]")
|
||||
stripped_lines.insert(0, "[interfaces]")
|
||||
|
||||
# parse lines as rns config object
|
||||
config = RNS.vendor.configobj.ConfigObj(lines)
|
||||
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")
|
||||
config_interfaces = config.get("interfaces", {})
|
||||
if config_interfaces is None:
|
||||
return []
|
||||
|
||||
# process interfaces
|
||||
interfaces = []
|
||||
for interface_name in config_interfaces:
|
||||
|
||||
# ensure interface has a name
|
||||
interface_config = config_interfaces[interface_name]
|
||||
interface_config["name"] = interface_name
|
||||
interfaces.append(interface_config)
|
||||
|
||||
return interfaces
|
||||
|
||||
@staticmethod
|
||||
def _parse_best_effort(lines):
|
||||
interfaces = []
|
||||
current_interface_name = None
|
||||
current_interface = {}
|
||||
current_sub_name = None
|
||||
current_sub = None
|
||||
|
||||
def commit_sub():
|
||||
nonlocal current_sub_name, current_sub
|
||||
if current_sub_name and current_sub is not None:
|
||||
current_interface[current_sub_name] = current_sub
|
||||
current_sub_name = None
|
||||
current_sub = None
|
||||
|
||||
def commit_interface():
|
||||
nonlocal current_interface_name, current_interface
|
||||
if current_interface_name:
|
||||
# shallow copy to avoid future mutation
|
||||
interfaces.append(dict(current_interface))
|
||||
current_interface_name = None
|
||||
current_interface = {}
|
||||
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if line == "" or line.startswith("#"):
|
||||
continue
|
||||
|
||||
if line.lower() == "[interfaces]":
|
||||
continue
|
||||
|
||||
if line.startswith("[[[") and line.endswith("]]]"):
|
||||
commit_sub()
|
||||
current_sub_name = line[3:-3].strip()
|
||||
current_sub = {}
|
||||
continue
|
||||
|
||||
if line.startswith("[[") and line.endswith("]]"):
|
||||
commit_sub()
|
||||
commit_interface()
|
||||
current_interface_name = line[2:-2].strip()
|
||||
current_interface = {"name": current_interface_name}
|
||||
continue
|
||||
|
||||
if "=" in line and current_interface_name is not None:
|
||||
key, value = line.split("=", 1)
|
||||
target = current_sub if current_sub is not None else current_interface
|
||||
target[key.strip()] = value.strip()
|
||||
|
||||
# commit any pending sections
|
||||
commit_sub()
|
||||
commit_interface()
|
||||
|
||||
return interfaces
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
class InterfaceEditor:
|
||||
|
||||
@staticmethod
|
||||
def update_value(interface_details: dict, data: dict, key: str):
|
||||
|
||||
# update value if provided and not empty
|
||||
value = data.get(key)
|
||||
if value is not None and value != "":
|
||||
@@ -10,5 +8,4 @@ class InterfaceEditor:
|
||||
return
|
||||
|
||||
# otherwise remove existing value
|
||||
if key in interface_details:
|
||||
del interface_details[key]
|
||||
interface_details.pop(key, None)
|
||||
|
||||
@@ -8,7 +8,6 @@ from websockets.sync.connection import Connection
|
||||
|
||||
|
||||
class WebsocketClientInterface(Interface):
|
||||
|
||||
# TODO: required?
|
||||
DEFAULT_IFAC_SIZE = 16
|
||||
|
||||
@@ -18,7 +17,6 @@ class WebsocketClientInterface(Interface):
|
||||
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
|
||||
|
||||
def __init__(self, owner, configuration, websocket: Connection = None):
|
||||
|
||||
super().__init__()
|
||||
|
||||
self.owner = owner
|
||||
@@ -26,8 +24,8 @@ class WebsocketClientInterface(Interface):
|
||||
|
||||
self.IN = True
|
||||
self.OUT = False
|
||||
self.HW_MTU = 262144 # 256KiB
|
||||
self.bitrate = 1_000_000_000 # 1Gbps
|
||||
self.HW_MTU = 262144 # 256KiB
|
||||
self.bitrate = 1_000_000_000 # 1Gbps
|
||||
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||
|
||||
# parse config
|
||||
@@ -48,7 +46,6 @@ class WebsocketClientInterface(Interface):
|
||||
|
||||
# called when a full packet has been received over the websocket
|
||||
def process_incoming(self, data):
|
||||
|
||||
# do nothing if offline or detached
|
||||
if not self.online or self.detached:
|
||||
return
|
||||
@@ -65,7 +62,6 @@ class WebsocketClientInterface(Interface):
|
||||
|
||||
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
|
||||
def process_outgoing(self, data):
|
||||
|
||||
# do nothing if offline or detached
|
||||
if not self.online or self.detached:
|
||||
return
|
||||
@@ -74,8 +70,10 @@ class WebsocketClientInterface(Interface):
|
||||
try:
|
||||
self.websocket.send(data)
|
||||
except Exception as e:
|
||||
RNS.log(f"Exception occurred while transmitting via {str(self)}", RNS.LOG_ERROR)
|
||||
RNS.log(f"The contained exception was: {str(e)}", RNS.LOG_ERROR)
|
||||
RNS.log(
|
||||
f"Exception occurred while transmitting via {self!s}", RNS.LOG_ERROR,
|
||||
)
|
||||
RNS.log(f"The contained exception was: {e!s}", RNS.LOG_ERROR)
|
||||
return
|
||||
|
||||
# update sent bytes counter
|
||||
@@ -87,27 +85,27 @@ class WebsocketClientInterface(Interface):
|
||||
|
||||
# connect to the configured websocket server
|
||||
def connect(self):
|
||||
|
||||
# do nothing if interface is detached
|
||||
if self.detached:
|
||||
return
|
||||
|
||||
# connect to websocket server
|
||||
try:
|
||||
RNS.log(f"Connecting to Websocket for {str(self)}...", RNS.LOG_DEBUG)
|
||||
self.websocket = connect(f"{self.target_url}", max_size=None, compression=None)
|
||||
RNS.log(f"Connected to Websocket for {str(self)}", RNS.LOG_DEBUG)
|
||||
RNS.log(f"Connecting to Websocket for {self!s}...", RNS.LOG_DEBUG)
|
||||
self.websocket = connect(
|
||||
f"{self.target_url}", max_size=None, compression=None,
|
||||
)
|
||||
RNS.log(f"Connected to Websocket for {self!s}", RNS.LOG_DEBUG)
|
||||
self.read_loop()
|
||||
except Exception as e:
|
||||
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
|
||||
|
||||
# auto reconnect after delay
|
||||
RNS.log(f"Websocket disconnected for {str(self)}...", RNS.LOG_DEBUG)
|
||||
RNS.log(f"Websocket disconnected for {self!s}...", RNS.LOG_DEBUG)
|
||||
time.sleep(self.RECONNECT_DELAY_SECONDS)
|
||||
self.connect()
|
||||
|
||||
def read_loop(self):
|
||||
|
||||
self.online = True
|
||||
|
||||
try:
|
||||
@@ -119,7 +117,6 @@ class WebsocketClientInterface(Interface):
|
||||
self.online = False
|
||||
|
||||
def detach(self):
|
||||
|
||||
# mark as offline
|
||||
self.online = False
|
||||
|
||||
@@ -130,5 +127,6 @@ class WebsocketClientInterface(Interface):
|
||||
# mark as detached
|
||||
self.detached = True
|
||||
|
||||
|
||||
# set interface class RNS should use when importing this external interface
|
||||
interface_class = WebsocketClientInterface
|
||||
|
||||
@@ -3,33 +3,31 @@ import time
|
||||
|
||||
import RNS
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from websockets.sync.server import Server
|
||||
from websockets.sync.server import serve
|
||||
from websockets.sync.server import ServerConnection
|
||||
from 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}]"
|
||||
return (
|
||||
f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
|
||||
)
|
||||
|
||||
def __init__(self, owner, configuration):
|
||||
|
||||
super().__init__()
|
||||
|
||||
self.owner = owner
|
||||
|
||||
self.IN = True
|
||||
self.OUT = False
|
||||
self.HW_MTU = 262144 # 256KiB
|
||||
self.bitrate = 1_000_000_000 # 1Gbps
|
||||
self.HW_MTU = 262144 # 256KiB
|
||||
self.bitrate = 1_000_000_000 # 1Gbps
|
||||
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
|
||||
|
||||
self.server: Server | None = None
|
||||
@@ -61,12 +59,12 @@ class WebsocketServerInterface(Interface):
|
||||
def clients(self):
|
||||
return len(self.spawned_interfaces)
|
||||
|
||||
# todo docs
|
||||
# TODO docs
|
||||
def received_announce(self, from_spawned=False):
|
||||
if from_spawned:
|
||||
self.ia_freq_deque.append(time.time())
|
||||
|
||||
# todo docs
|
||||
# TODO docs
|
||||
def sent_announce(self, from_spawned=False):
|
||||
if from_spawned:
|
||||
self.oa_freq_deque.append(time.time())
|
||||
@@ -80,17 +78,19 @@ class WebsocketServerInterface(Interface):
|
||||
pass
|
||||
|
||||
def serve(self):
|
||||
|
||||
# handle new websocket client connections
|
||||
def on_websocket_client_connected(websocket: ServerConnection):
|
||||
|
||||
# create new child interface
|
||||
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
|
||||
spawned_interface = WebsocketClientInterface(self.owner, {
|
||||
"name": f"Client on {self.name}",
|
||||
"target_host": websocket.remote_address[0],
|
||||
"target_port": str(websocket.remote_address[1]),
|
||||
}, websocket=websocket)
|
||||
spawned_interface = WebsocketClientInterface(
|
||||
self.owner,
|
||||
{
|
||||
"name": f"Client on {self.name}",
|
||||
"target_host": websocket.remote_address[0],
|
||||
"target_port": str(websocket.remote_address[1]),
|
||||
},
|
||||
websocket=websocket,
|
||||
)
|
||||
|
||||
# configure child interface
|
||||
spawned_interface.IN = self.IN
|
||||
@@ -101,16 +101,19 @@ class WebsocketServerInterface(Interface):
|
||||
spawned_interface.parent_interface = self
|
||||
spawned_interface.online = True
|
||||
|
||||
# todo implement?
|
||||
# TODO implement?
|
||||
spawned_interface.announce_rate_target = None
|
||||
spawned_interface.announce_rate_grace = None
|
||||
spawned_interface.announce_rate_penalty = None
|
||||
|
||||
# todo ifac?
|
||||
# todo announce rates?
|
||||
# TODO ifac?
|
||||
# TODO announce rates?
|
||||
|
||||
# activate child interface
|
||||
RNS.log(f"Spawned new WebsocketClientInterface: {spawned_interface}", RNS.LOG_VERBOSE)
|
||||
RNS.log(
|
||||
f"Spawned new WebsocketClientInterface: {spawned_interface}",
|
||||
RNS.LOG_VERBOSE,
|
||||
)
|
||||
RNS.Transport.interfaces.append(spawned_interface)
|
||||
|
||||
# associate child interface with this interface
|
||||
@@ -126,8 +129,13 @@ class WebsocketServerInterface(Interface):
|
||||
|
||||
# run websocket server
|
||||
try:
|
||||
RNS.log(f"Starting Websocket server for {str(self)}...", RNS.LOG_DEBUG)
|
||||
with serve(on_websocket_client_connected, self.listen_ip, self.listen_port, compression=None) as server:
|
||||
RNS.log(f"Starting Websocket server for {self!s}...", RNS.LOG_DEBUG)
|
||||
with serve(
|
||||
on_websocket_client_connected,
|
||||
self.listen_ip,
|
||||
self.listen_port,
|
||||
compression=None,
|
||||
) as server:
|
||||
self.online = True
|
||||
self.server = server
|
||||
server.serve_forever()
|
||||
@@ -136,12 +144,11 @@ class WebsocketServerInterface(Interface):
|
||||
|
||||
# websocket server is no longer running, let's restart it
|
||||
self.online = False
|
||||
RNS.log(f"Websocket server stopped for {str(self)}...", RNS.LOG_DEBUG)
|
||||
RNS.log(f"Websocket server stopped for {self!s}...", RNS.LOG_DEBUG)
|
||||
time.sleep(self.RESTART_DELAY_SECONDS)
|
||||
self.serve()
|
||||
|
||||
def detach(self):
|
||||
|
||||
# mark as offline
|
||||
self.online = False
|
||||
|
||||
@@ -152,5 +159,6 @@ class WebsocketServerInterface(Interface):
|
||||
# mark as detached
|
||||
self.detached = True
|
||||
|
||||
|
||||
# set interface class RNS should use when importing this external interface
|
||||
interface_class = WebsocketServerInterface
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
from typing import List
|
||||
|
||||
|
||||
# helper class for passing around an lxmf audio field
|
||||
class LxmfAudioField:
|
||||
|
||||
def __init__(self, audio_mode: int, audio_bytes: bytes):
|
||||
self.audio_mode = audio_mode
|
||||
self.audio_bytes = audio_bytes
|
||||
@@ -11,7 +7,6 @@ class LxmfAudioField:
|
||||
|
||||
# helper class for passing around an lxmf image field
|
||||
class LxmfImageField:
|
||||
|
||||
def __init__(self, image_type: str, image_bytes: bytes):
|
||||
self.image_type = image_type
|
||||
self.image_bytes = image_bytes
|
||||
@@ -19,7 +14,6 @@ class LxmfImageField:
|
||||
|
||||
# helper class for passing around an lxmf file attachment
|
||||
class LxmfFileAttachment:
|
||||
|
||||
def __init__(self, file_name: str, file_bytes: bytes):
|
||||
self.file_name = file_name
|
||||
self.file_bytes = file_bytes
|
||||
@@ -27,7 +21,5 @@ class LxmfFileAttachment:
|
||||
|
||||
# helper class for passing around an lxmf file attachments field
|
||||
class LxmfFileAttachmentsField:
|
||||
|
||||
def __init__(self, file_attachments: List[LxmfFileAttachment]):
|
||||
def __init__(self, file_attachments: list[LxmfFileAttachment]):
|
||||
self.file_attachments = file_attachments
|
||||
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
|
||||
<title>Phone | Reticulum MeshChat</title>
|
||||
|
||||
<!-- codec2 -->
|
||||
<script src="assets/js/codec2-emscripten/c2enc.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/c2dec.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/sox.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/codec2-lib.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import axios from 'axios';
|
||||
import {createApp} from 'vue';
|
||||
import { createApp } from 'vue';
|
||||
import "./style.css";
|
||||
import CallPage from "./components/call/CallPage.vue";
|
||||
import { ensureCodec2ScriptsLoaded } from "./js/Codec2Loader";
|
||||
|
||||
// provide axios globally
|
||||
window.axios = axios;
|
||||
|
||||
createApp(CallPage)
|
||||
.mount('#app');
|
||||
async function bootstrap() {
|
||||
await ensureCodec2ScriptsLoaded();
|
||||
createApp(CallPage)
|
||||
.mount('#app');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -1,54 +1,86 @@
|
||||
<template>
|
||||
<div :class="{'dark': config?.theme === 'dark'}" class="h-screen w-full flex flex-col">
|
||||
<div :class="{'dark': config?.theme === 'dark'}" class="h-screen w-full flex flex-col bg-slate-50 dark:bg-zinc-950 transition-colors">
|
||||
|
||||
<!-- header -->
|
||||
<div class="flex bg-white dark:bg-zinc-950 p-2 border-gray-300 dark:border-zinc-900 border-b min-h-16">
|
||||
<div class="flex w-full">
|
||||
<div class="hidden sm:flex my-auto w-12 h-12 mr-2">
|
||||
<img class="w-12 h-12" src="/assets/images/logo-chat-bubble.png" />
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<div @click="onAppNameClick" class="font-bold cursor-pointer text-gray-900 dark:text-zinc-100">Reticulum MeshChat</div>
|
||||
<div class="text-sm text-gray-700 dark:text-white">
|
||||
Developed by
|
||||
<a target="_blank" href="https://liamcottle.com" class="text-blue-500 dark:text-blue-400">Liam Cottle</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
|
||||
<button @click="syncPropagationNode" type="button" class="rounded-full">
|
||||
<span class="flex text-gray-700 dark:text-white bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-600 px-2 py-1 rounded-full">
|
||||
<span :class="{ 'animate-spin': isSyncingPropagationNode }">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm">Sync Messages</span>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="composeNewMessage" type="button" class="rounded-full">
|
||||
<span class="flex text-gray-700 dark:text-white bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-600 px-2 py-1 rounded-full">
|
||||
<span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm">Compose</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isPopoutMode" class="flex flex-1 h-full w-full overflow-hidden bg-slate-50/90 dark:bg-zinc-950">
|
||||
<RouterView class="flex-1"/>
|
||||
</div>
|
||||
|
||||
<!-- middle -->
|
||||
<div ref="middle" class="flex h-full w-full overflow-auto">
|
||||
<template v-else>
|
||||
|
||||
<!-- sidebar -->
|
||||
<div class="bg-white flex w-72 min-w-72 flex-col dark:bg-zinc-950">
|
||||
<div class="flex grow flex-col overflow-y-auto border-r border-gray-200 bg-white dark:border-zinc-900 dark:bg-zinc-950">
|
||||
<!-- header -->
|
||||
<div class="flex bg-white/80 dark:bg-zinc-900/70 backdrop-blur border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-sm transition-colors">
|
||||
<div class="flex w-full">
|
||||
<div class="hidden sm:flex my-auto w-12 h-12 mr-2 rounded-xl overflow-hidden bg-white/70 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 shadow-inner">
|
||||
<img class="w-12 h-12 object-contain p-1.5" src="/assets/images/logo-chat-bubble.png" />
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<div @click="onAppNameClick" class="font-semibold cursor-pointer text-gray-900 dark:text-zinc-100 tracking-tight text-lg">Reticulum MeshChatX</div>
|
||||
<div class="text-sm text-gray-600 dark:text-zinc-300">
|
||||
Custom fork by
|
||||
<a target="_blank" href="https://github.com/Sudo-Ivan" class="text-blue-500 dark:text-blue-300 hover:underline">Sudo-Ivan</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-2">
|
||||
<button @click="syncPropagationNode" type="button" class="rounded-full">
|
||||
<span class="flex text-gray-800 dark:text-zinc-100 bg-white dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 hover:border-blue-400 dark:hover:border-blue-400/60 px-3 py-1.5 rounded-full shadow-sm transition">
|
||||
<span :class="{ 'animate-spin': isSyncingPropagationNode }">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-medium">Sync Messages</span>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="composeNewMessage" type="button" class="rounded-full">
|
||||
<span class="flex text-white bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 hover:from-blue-500/90 hover:to-purple-500/90 px-3 py-1.5 rounded-full shadow-md transition">
|
||||
<span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-semibold">Compose</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- navigation -->
|
||||
<div class="flex-1">
|
||||
<ul class="py-2 pr-2 space-y-1">
|
||||
<!-- onboarding / guidance -->
|
||||
<div v-if="hasGuidanceMessages" class="border-b border-amber-200/60 bg-amber-50/70 text-amber-900 dark:bg-amber-950/30 dark:border-amber-800/40 dark:text-amber-100 transition">
|
||||
<div class="max-w-5xl mx-auto px-4 py-4 space-y-3">
|
||||
<div
|
||||
v-for="message in guidanceMessages"
|
||||
:key="message.id"
|
||||
class="flex flex-col gap-2 rounded-2xl border p-4 text-sm sm:flex-row sm:items-center shadow-sm"
|
||||
:class="guidanceCardClass(message)"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<div class="font-semibold">{{ message.title }}</div>
|
||||
<div class="text-xs sm:text-sm text-amber-900/80 dark:text-amber-100/80">{{ message.description }}</div>
|
||||
</div>
|
||||
<div v-if="message.action_route" class="sm:ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
@click="navigateTo(message.action_route)"
|
||||
class="inline-flex items-center rounded-full bg-amber-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-amber-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
|
||||
>
|
||||
{{ message.action_label || 'Open' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- middle -->
|
||||
<div ref="middle" class="flex h-full w-full overflow-hidden bg-slate-50/80 dark:bg-zinc-950 transition-colors">
|
||||
|
||||
<!-- sidebar -->
|
||||
<div class="bg-transparent flex w-72 min-w-72 flex-col">
|
||||
<div class="flex grow flex-col overflow-y-auto border-r border-gray-200/70 bg-white/80 dark:border-zinc-800 dark:bg-zinc-900/70 backdrop-blur">
|
||||
|
||||
<!-- navigation -->
|
||||
<div class="flex-1">
|
||||
<ul class="py-3 pr-2 space-y-1">
|
||||
|
||||
<!-- messages -->
|
||||
<li>
|
||||
@@ -144,8 +176,8 @@
|
||||
<div>
|
||||
|
||||
<!-- my identity -->
|
||||
<div v-if="config" class="bg-white border-t dark:border-zinc-900 dark:bg-zinc-950">
|
||||
<div @click="isShowingMyIdentitySection = !isShowingMyIdentitySection" class="flex text-gray-700 p-2 cursor-pointer">
|
||||
<div v-if="config" class="bg-white/80 border-t dark:border-zinc-800 dark:bg-zinc-900/70 backdrop-blur">
|
||||
<div @click="isShowingMyIdentitySection = !isShowingMyIdentitySection" class="flex text-gray-700 p-3 cursor-pointer">
|
||||
<div class="my-auto mr-2">
|
||||
<RouterLink @click.stop :to="{ name: 'profile.icon' }">
|
||||
<LxmfUserIcon
|
||||
@@ -162,8 +194,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isShowingMyIdentitySection" class="divide-y text-gray-900 border-t border-gray-300 dark:text-zinc-200 dark:border-zinc-900">
|
||||
<div class="p-1">
|
||||
<div v-if="isShowingMyIdentitySection" class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-800">
|
||||
<div class="p-2">
|
||||
<input
|
||||
v-model="displayName"
|
||||
type="text"
|
||||
@@ -172,11 +204,11 @@
|
||||
dark:bg-zinc-800 dark:border-zinc-600 dark:text-zinc-200 dark:focus:ring-blue-400 dark:focus:border-blue-400"
|
||||
>
|
||||
</div>
|
||||
<div class="p-1 dark:border-zinc-900">
|
||||
<div class="p-2 dark:border-zinc-900">
|
||||
<div>Identity Hash</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.identity_hash }}</div>
|
||||
</div>
|
||||
<div class="p-1 dark:border-zinc-900">
|
||||
<div class="p-2 dark:border-zinc-900">
|
||||
<div>LXMF Address</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.lxmf_address_hash }}</div>
|
||||
</div>
|
||||
@@ -184,8 +216,8 @@
|
||||
</div>
|
||||
|
||||
<!-- auto announce -->
|
||||
<div v-if="config" class="bg-white border-t dark:bg-zinc-950 dark:border-zinc-900">
|
||||
<div @click="isShowingAnnounceSection = !isShowingAnnounceSection" class="flex text-gray-700 p-2 cursor-pointer dark:text-white">
|
||||
<div v-if="config" class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-800">
|
||||
<div @click="isShowingAnnounceSection = !isShowingAnnounceSection" class="flex text-gray-700 p-3 cursor-pointer dark:text-white">
|
||||
<div class="my-auto mr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -214,8 +246,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isShowingAnnounceSection" class="divide-y text-gray-900 border-t border-gray-300 dark:text-zinc-200 dark:border-zinc-900">
|
||||
<div class="p-1 dark:border-zinc-900">
|
||||
<div v-if="isShowingAnnounceSection" class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-900">
|
||||
<div class="p-2 dark:border-zinc-900">
|
||||
<select
|
||||
v-model="config.auto_announce_interval_seconds"
|
||||
@change="onAnnounceIntervalSecondsChange"
|
||||
@@ -240,8 +272,8 @@
|
||||
</div>
|
||||
|
||||
<!-- audio calls -->
|
||||
<div v-if="config" class="bg-white border-t dark:bg-zinc-950 dark:border-zinc-900">
|
||||
<div @click="isShowingCallsSection = !isShowingCallsSection" class="flex text-gray-700 p-2 cursor-pointer">
|
||||
<div v-if="config" class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-900">
|
||||
<div @click="isShowingCallsSection = !isShowingCallsSection" class="flex text-gray-700 p-3 cursor-pointer">
|
||||
<div class="my-auto mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="dark:text-white w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
|
||||
@@ -249,19 +281,15 @@
|
||||
</div>
|
||||
<div class="my-auto dark:text-white">Calls</div>
|
||||
<div class="ml-auto">
|
||||
<a @click.stop href="../call.html" target="_blank" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500
|
||||
dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500
|
||||
">
|
||||
<span>Open Phone</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Z" clip-rule="evenodd" />
|
||||
<path fill-rule="evenodd" d="M6.194 12.753a.75.75 0 0 0 1.06.053L16.5 4.44v2.81a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.553l-9.056 8.194a.75.75 0 0 0-.053 1.06Z" clip-rule="evenodd" />
|
||||
<RouterLink :to="{ name: 'call' }" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 transition-colors overflow-hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4 flex-shrink-0">
|
||||
<path fill-rule="evenodd" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isShowingCallsSection" class="divide-y text-gray-900 border-t border-gray-300 dark:border-zinc-900">
|
||||
<div class="p-1 flex dark:border-zinc-900 dark:text-white">
|
||||
<div v-if="isShowingCallsSection" class="divide-y text-gray-900 border-t border-gray-200 dark:border-zinc-900">
|
||||
<div class="p-2 flex dark:border-zinc-900 dark:text-white">
|
||||
<div>
|
||||
<div>Status</div>
|
||||
<div class="text-sm text-gray-700 dark:text-white">
|
||||
@@ -299,9 +327,10 @@ dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outli
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterView/>
|
||||
<RouterView v-if="!isPopoutMode"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -326,6 +355,7 @@ export default {
|
||||
return {
|
||||
|
||||
reloadInterval: null,
|
||||
appInfoInterval: null,
|
||||
|
||||
isShowingMyIdentitySection: true,
|
||||
isShowingAnnounceSection: true,
|
||||
@@ -343,6 +373,7 @@ export default {
|
||||
beforeUnmount() {
|
||||
|
||||
clearInterval(this.reloadInterval);
|
||||
clearInterval(this.appInfoInterval);
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
@@ -354,6 +385,7 @@ export default {
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
|
||||
this.getAppInfo();
|
||||
this.getConfig();
|
||||
this.updateCallsList();
|
||||
this.updatePropagationNodeStatus();
|
||||
|
||||
@@ -362,9 +394,52 @@ export default {
|
||||
this.updateCallsList();
|
||||
this.updatePropagationNodeStatus();
|
||||
}, 3000);
|
||||
this.appInfoInterval = setInterval(() => {
|
||||
this.getAppInfo();
|
||||
}, 15000);
|
||||
|
||||
},
|
||||
computed: {
|
||||
currentPopoutType() {
|
||||
if(this.$route?.meta?.popoutType){
|
||||
return this.$route.meta.popoutType;
|
||||
}
|
||||
return this.$route?.query?.popout ?? this.getHashPopoutValue();
|
||||
},
|
||||
isPopoutMode() {
|
||||
return this.currentPopoutType != null;
|
||||
},
|
||||
hasGuidanceMessages() {
|
||||
return this.guidanceMessages.length > 0;
|
||||
},
|
||||
guidanceMessages() {
|
||||
if (!this.appInfo || !Array.isArray(this.appInfo.user_guidance)) {
|
||||
return [];
|
||||
}
|
||||
return this.appInfo.user_guidance;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
guidanceCardClass(message) {
|
||||
switch(message.severity){
|
||||
case 'warning':
|
||||
return 'border-amber-200 bg-white text-amber-900 dark:bg-transparent dark:border-amber-300/40';
|
||||
case 'info':
|
||||
default:
|
||||
return 'border-amber-100 bg-white text-amber-900 dark:bg-transparent dark:border-amber-200/30';
|
||||
}
|
||||
},
|
||||
navigateTo(routePath) {
|
||||
if (!routePath) {
|
||||
return;
|
||||
}
|
||||
this.$router.push(routePath);
|
||||
},
|
||||
getHashPopoutValue() {
|
||||
const hash = window.location.hash || "";
|
||||
const match = hash.match(/popout=([^&]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
async onWebsocketMessage(message) {
|
||||
const json = JSON.parse(message.data);
|
||||
switch(json.type){
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<button type="button" class="text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 rounded-full">
|
||||
<button type="button" class="text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 rounded-full w-8 h-8 flex items-center justify-center flex-shrink-0">
|
||||
<slot/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,118 +1,200 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto space-y-2 p-2">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900">
|
||||
<div class="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="bg-white dark:bg-zinc-900 rounded shadow">
|
||||
<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="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
||||
<div v-if="appInfo" class="glass-card">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">About</div>
|
||||
<div class="text-3xl font-semibold text-gray-900 dark:text-white">Reticulum MeshChatX</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
v{{ appInfo.version }} • RNS {{ appInfo.rns_version }} • LXMF {{ appInfo.lxmf_version }} • Python {{ appInfo.python_version }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isElectron" class="flex flex-col sm:flex-row gap-2">
|
||||
<button @click="relaunch" type="button" class="primary-chip px-4 py-2 text-sm justify-center">
|
||||
<MaterialDesignIcon icon-name="restart" class="w-4 h-4"/>
|
||||
Restart App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3 mt-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>
|
||||
<div class="glass-label">Config path</div>
|
||||
<div class="monospace-field break-all">{{ appInfo.reticulum_config_path }}</div>
|
||||
<button v-if="isElectron" @click="showReticulumConfigFile" type="button" class="secondary-chip mt-2 text-xs">
|
||||
<MaterialDesignIcon icon-name="folder" class="w-4 h-4"/>
|
||||
Reveal
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Database path</div>
|
||||
<div class="monospace-field break-all">{{ appInfo.database_path }}</div>
|
||||
<button v-if="isElectron" @click="showDatabaseFile" type="button" class="secondary-chip mt-2 text-xs">
|
||||
<MaterialDesignIcon icon-name="database" class="w-4 h-4"/>
|
||||
Reveal
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Database size</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ formatBytes(appInfo.database_file_size) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- version -->
|
||||
<div class="flex p-1">
|
||||
<div class="mr-auto">
|
||||
<div>Versions</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
||||
MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }} • Python v{{ appInfo.python_version }}
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div v-if="appInfo?.memory_usage" class="glass-card space-y-3">
|
||||
<header class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="chip" class="w-5 h-5 text-blue-500"/>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">System Resources</div>
|
||||
<div class="text-xs text-emerald-500 flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden sm:block mx-2 my-auto">
|
||||
<a target="_blank"
|
||||
href="https://github.com/liamcottle/reticulum-meshchat/releases"
|
||||
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">
|
||||
Check for Updates
|
||||
</a>
|
||||
</header>
|
||||
<div class="metric-row">
|
||||
<div>
|
||||
<div class="glass-label">Memory (RSS)</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.rss) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Virtual Memory</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.vms) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- reticulum status -->
|
||||
<div v-if="appInfo" class="bg-white dark:bg-zinc-900 rounded shadow">
|
||||
<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>
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
||||
|
||||
<!-- instance mode -->
|
||||
<div class="p-1">
|
||||
<div>Instance Mode</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
||||
<span v-if="appInfo.is_connected_to_shared_instance" class="text-orange-600 dark:text-orange-400">Connected to Shared Instance</span>
|
||||
<span v-else class="text-green-600 dark:text-green-400">Running as Standalone Instance</span>
|
||||
<div v-if="appInfo?.network_stats" class="glass-card space-y-3">
|
||||
<header class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="access-point-network" class="w-5 h-5 text-purple-500"/>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">Network Stats</div>
|
||||
<div class="text-xs text-emerald-500 flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="metric-row">
|
||||
<div>
|
||||
<div class="glass-label">Sent</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.network_stats.bytes_sent) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Received</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.network_stats.bytes_recv) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- transport mode -->
|
||||
<div class="p-1">
|
||||
<div>Transport Mode</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
||||
<span v-if="appInfo.is_transport_enabled" class="text-green-600 dark:text-green-400">Transport Enabled</span>
|
||||
<span v-else class="text-orange-600 dark:text-orange-400">Transport Disabled</span>
|
||||
<div class="metric-row">
|
||||
<div>
|
||||
<div class="glass-label">Packets Sent</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_sent) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Packets Received</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_recv) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- my addresses -->
|
||||
<div v-if="config" class="bg-white dark:bg-zinc-900 rounded shadow">
|
||||
<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>
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
||||
<div class="p-1">
|
||||
<div>Identity Hash</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.identity_hash }}</div>
|
||||
<div v-if="appInfo?.reticulum_stats" class="glass-card space-y-3">
|
||||
<header class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="diagram-projector" class="w-5 h-5 text-indigo-500"/>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">Reticulum Stats</div>
|
||||
<div class="text-xs text-emerald-500 flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="metric-grid">
|
||||
<div>
|
||||
<div class="glass-label">Total Paths</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.total_paths) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Announces / sec</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.announces_per_second) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Announces / min</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.announces_per_minute) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Announces / hr</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.announces_per_hour) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<div>LXMF Address</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.lxmf_address_hash }}</div>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<div>LXMF Propagation Node Address</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.lxmf_local_propagation_node_address_hash }}</div>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<div>Audio Call Address</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.audio_call_address_hash }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="appInfo?.download_stats" class="glass-card space-y-3">
|
||||
<header class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="download" class="w-5 h-5 text-sky-500"/>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">Download Activity</div>
|
||||
<div class="text-xs text-emerald-500 flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="metric-value">
|
||||
<span v-if="appInfo.download_stats.avg_download_speed_bps !== null">
|
||||
{{ formatBytesPerSecond(appInfo.download_stats.avg_download_speed_bps) }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-500">No downloads yet</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="appInfo" class="glass-card space-y-3">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">Runtime Status</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<span :class="statusPillClass(!appInfo.is_connected_to_shared_instance)">
|
||||
<MaterialDesignIcon icon-name="server" class="w-4 h-4"/>
|
||||
{{ appInfo.is_connected_to_shared_instance ? 'Shared Instance' : 'Standalone Instance' }}
|
||||
</span>
|
||||
<span :class="statusPillClass(appInfo.is_transport_enabled)">
|
||||
<MaterialDesignIcon icon-name="transit-connection" class="w-4 h-4"/>
|
||||
{{ appInfo.is_transport_enabled ? 'Transport Enabled' : 'Transport Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="config" class="glass-card space-y-4">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">Identity & Addresses</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="address-card">
|
||||
<div class="glass-label">Identity Hash</div>
|
||||
<div class="monospace-field break-all">{{ config.identity_hash }}</div>
|
||||
<button @click="copyValue(config.identity_hash, 'Identity Hash')" type="button" class="secondary-chip mt-3 text-xs">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="address-card">
|
||||
<div class="glass-label">LXMF Address</div>
|
||||
<div class="monospace-field break-all">{{ config.lxmf_address_hash }}</div>
|
||||
<button @click="copyValue(config.lxmf_address_hash, 'LXMF Address')" type="button" class="secondary-chip mt-3 text-xs">
|
||||
<MaterialDesignIcon icon-name="account-network" class="w-4 h-4"/>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="address-card">
|
||||
<div class="glass-label">Propagation Node</div>
|
||||
<div class="monospace-field break-all">{{ config.lxmf_local_propagation_node_address_hash || '—' }}</div>
|
||||
</div>
|
||||
<div class="address-card">
|
||||
<div class="glass-label">Audio Call Address</div>
|
||||
<div class="monospace-field break-all">{{ config.audio_call_address_hash || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -120,17 +202,32 @@
|
||||
<script>
|
||||
import Utils from "../../js/Utils";
|
||||
import ElectronUtils from "../../js/ElectronUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
export default {
|
||||
name: 'AboutPage',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
appInfo: null,
|
||||
config: null,
|
||||
updateInterval: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.getAppInfo();
|
||||
this.getConfig();
|
||||
// Update stats every 5 seconds
|
||||
this.updateInterval = setInterval(() => {
|
||||
this.getAppInfo();
|
||||
}, 5000);
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async getAppInfo() {
|
||||
@@ -151,6 +248,20 @@ export default {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async copyValue(value, label) {
|
||||
if(!value){
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
DialogUtils.toast?.(`${label} copied`) ?? DialogUtils.alert(`${label} copied to clipboard`);
|
||||
} catch(e) {
|
||||
DialogUtils.alert(`Failed to copy ${label}`);
|
||||
}
|
||||
},
|
||||
relaunch() {
|
||||
ElectronUtils.relaunch();
|
||||
},
|
||||
showReticulumConfigFile() {
|
||||
const reticulumConfigPath = this.appInfo.reticulum_config_path;
|
||||
if(reticulumConfigPath){
|
||||
@@ -166,6 +277,17 @@ export default {
|
||||
formatBytes: function(bytes) {
|
||||
return Utils.formatBytes(bytes);
|
||||
},
|
||||
formatNumber: function(num) {
|
||||
return Utils.formatNumber(num);
|
||||
},
|
||||
formatBytesPerSecond: function(bytesPerSecond) {
|
||||
return Utils.formatBytesPerSecond(bytesPerSecond);
|
||||
},
|
||||
statusPillClass(isGood) {
|
||||
return isGood
|
||||
? "inline-flex items-center gap-1 rounded-full bg-emerald-100 text-emerald-700 px-3 py-1 text-xs font-semibold"
|
||||
: "inline-flex items-center gap-1 rounded-full bg-orange-100 text-orange-700 px-3 py-1 text-xs font-semibold";
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isElectron() {
|
||||
|
||||
@@ -1,61 +1,62 @@
|
||||
<template>
|
||||
<div class="flex w-full h-full bg-gray-100 dark:bg-zinc-950" :class="{'dark': config?.theme === 'dark'}">
|
||||
<div class="mx-auto my-auto w-full max-w-xl p-4">
|
||||
<div class="flex w-full h-full bg-gray-50 dark:bg-zinc-950" :class="{'dark': config?.theme === 'dark'}">
|
||||
<div class="mx-auto my-auto w-full max-w-2xl p-4 sm:p-6">
|
||||
|
||||
<!-- in active call -->
|
||||
<div v-if="isWebsocketConnected" class="w-full">
|
||||
<div class="border rounded-xl bg-white shadow w-full">
|
||||
<div class="flex border-b border-gray-300 text-gray-700 p-2">
|
||||
<div class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-lg w-full overflow-hidden">
|
||||
<div class="flex items-center border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm px-4 py-3">
|
||||
<div class="my-auto mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto">Active Call</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-zinc-100">Active Call</div>
|
||||
</div>
|
||||
<div class="border-b border-gray-300 text-gray-700 p-2">
|
||||
<div class="border-b border-gray-200 dark:border-zinc-800 p-4 space-y-3">
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">Call Hash</div>
|
||||
<div class="text-xs text-gray-600">{{ audioCall?.hash || "Unknown" }}</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Call Hash</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono">{{ audioCall?.hash || "Unknown" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">Remote Identity Hash</div>
|
||||
<div class="text-xs text-gray-600">{{ audioCall?.remote_identity_hash || "Unknown" }}</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Remote Identity Hash</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono">{{ audioCall?.remote_identity_hash || "Unknown" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">Remote Destination Hash</div>
|
||||
<div class="text-xs text-gray-600">{{ audioCall?.remote_destination_hash || "Unknown" }}</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Remote Destination Hash</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono">{{ audioCall?.remote_destination_hash || "Unknown" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">Path</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Path</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100">
|
||||
<span v-if="audioCall?.path">{{ audioCall.path.hops }} {{ audioCall.path.hops === 1 ? 'hop' : 'hops' }} away via {{ audioCall.path.next_hop_interface }}</span>
|
||||
<span v-else>Unknown</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">TX Bytes</div>
|
||||
<div class="text-xs text-gray-600">{{ formatBytes(txBytes) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">RX Bytes</div>
|
||||
<div class="text-xs text-gray-600">{{ formatBytes(rxBytes) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">Incoming Audio</div>
|
||||
<div class="text-xs text-gray-600">{{ remoteAudioCodec || "Unknown" }}</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">TX Bytes</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100 font-semibold">{{ formatBytes(txBytes) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">RX Bytes</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100 font-semibold">{{ formatBytes(rxBytes) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-sm font-medium text-gray-900">Outgoing Audio</div>
|
||||
<select v-model="codecMode" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Incoming Audio</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100">{{ remoteAudioCodec || "Unknown" }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Outgoing Audio</div>
|
||||
<select v-model="codecMode" class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 block w-full px-3 py-2 shadow-sm transition-all">
|
||||
<option value="MODE_3200">Codec2 3200</option>
|
||||
<option value="MODE_2400">Codec2 2400</option>
|
||||
<option value="MODE_1600">Codec2 1600</option>
|
||||
@@ -69,10 +70,10 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="flex text-gray-900 p-2">
|
||||
<div class="flex items-center gap-2 px-4 py-3 bg-gray-50 dark:bg-zinc-900/50">
|
||||
|
||||
<!-- toggle mic -->
|
||||
<button @click="isMicMuted = !isMicMuted" type="button" :class="[ isMicMuted ? 'bg-red-500 hover:bg-red-400 focus-visible:outline-red-500' : 'bg-gray-500 hover:bg-gray-400 focus-visible:outline-gray-500' ]" class="my-auto inline-flex items-center gap-x-1 rounded-full p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<button @click="isMicMuted = !isMicMuted" type="button" :class="[ isMicMuted ? 'bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 focus-visible:outline-red-500' : 'bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 focus-visible:outline-gray-500' ]" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<svg v-if="isMicMuted" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="w-5 h-5">
|
||||
<path d="M213.38,229.92a8,8,0,0,1-11.3-.54l-30.92-34A78.83,78.83,0,0,1,136,207.59V240a8,8,0,0,1-16,0V207.6A80.11,80.11,0,0,1,48,128a8,8,0,0,1,16,0,64.07,64.07,0,0,0,64,64,63.41,63.41,0,0,0,32.21-8.68l-11.1-12.2A48,48,0,0,1,80,128V95.09L42.08,53.38A8,8,0,0,1,53.92,42.62l160,176A8,8,0,0,1,213.38,229.92Zm-24.19-63.13a7.88,7.88,0,0,0,3.51.82,8,8,0,0,0,7.19-4.49A79.16,79.16,0,0,0,208,128a8,8,0,0,0-16,0,63.32,63.32,0,0,1-6.48,28.09A8,8,0,0,0,189.19,166.79Zm-27.33-29.22A8,8,0,0,0,175.74,133a49.49,49.49,0,0,0,.26-5V64A48,48,0,0,0,84,44.87a8,8,0,0,0,1.41,8.57Z"></path>
|
||||
</svg>
|
||||
@@ -82,7 +83,7 @@
|
||||
</button>
|
||||
|
||||
<!-- toggle sound -->
|
||||
<button @click="isSoundMuted = !isSoundMuted" type="button" :class="[ isSoundMuted ? 'bg-red-500 hover:bg-red-400 focus-visible:outline-red-500' : 'bg-gray-500 hover:bg-gray-400 focus-visible:outline-gray-500' ]" class="ml-1 my-auto inline-flex items-center gap-x-1 rounded-full p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<button @click="isSoundMuted = !isSoundMuted" type="button" :class="[ isSoundMuted ? 'bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 focus-visible:outline-red-500' : 'bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 focus-visible:outline-gray-500' ]" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<svg v-if="isSoundMuted" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path d="M10.047 3.062a.75.75 0 0 1 .453.688v12.5a.75.75 0 0 1-1.264.546L5.203 13H2.667a.75.75 0 0 1-.7-.48A6.985 6.985 0 0 1 1.5 10c0-.887.165-1.737.468-2.52a.75.75 0 0 1 .7-.48h2.535l4.033-3.796a.75.75 0 0 1 .811-.142ZM13.78 7.22a.75.75 0 1 0-1.06 1.06L14.44 10l-1.72 1.72a.75.75 0 0 0 1.06 1.06l1.72-1.72 1.72 1.72a.75.75 0 1 0 1.06-1.06L16.56 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L15.5 8.94l-1.72-1.72Z" />
|
||||
</svg>
|
||||
@@ -93,7 +94,7 @@
|
||||
</button>
|
||||
|
||||
<!-- leave call -->
|
||||
<button @click="leaveCall" type="button" class="ml-auto mr-1 my-auto inline-flex items-center gap-x-1 rounded-full bg-blue-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
<button @click="leaveCall" type="button" class="ml-auto inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@@ -101,7 +102,7 @@
|
||||
</button>
|
||||
|
||||
<!-- hangup call -->
|
||||
<button @click="hangupCall(audioCall.hash)" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full bg-red-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
<button @click="hangupCall(audioCall.hash)" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 rotate-[135deg] translate-y-0.5">
|
||||
<path fill-rule="evenodd" d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@@ -116,60 +117,56 @@
|
||||
<div v-else class="w-full space-y-2">
|
||||
|
||||
<!-- dialer -->
|
||||
<div class="border rounded-xl bg-white shadow w-full overflow-hidden dark:border-zinc-900">
|
||||
<div class="flex border-b border-gray-300 text-gray-700 p-2 dark:bg-zinc-800 dark:text-white">
|
||||
<div class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-lg w-full overflow-hidden">
|
||||
<div class="flex items-center border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm px-4 py-3">
|
||||
<div class="my-auto mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 dark:text-white">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto">Start a new Call</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-zinc-100">Start a new Call</div>
|
||||
</div>
|
||||
<div class="flex border-b border-gray-300 text-gray-900 p-2 space-x-2 dark:bg-zinc-700 dark:text-zinc-100 dark:border-zinc-800">
|
||||
<div class="flex-1">
|
||||
<input v-model="destinationHash" type="text" placeholder="Enter Destination Hash" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2 dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100">
|
||||
<div class="p-4 space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<input v-model="destinationHash" @keydown.enter.exact.prevent="initiateCall(destinationHash)" type="text" placeholder="Enter Destination Hash" class="flex-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500">
|
||||
<button @click="initiateCall(destinationHash)" :disabled="isInitiatingCall || !destinationHash || destinationHash.trim() === ''" type="button" :class="[ isInitiatingCall || !destinationHash || destinationHash.trim() === '' ? 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 focus-visible:outline-green-500' ]" class="inline-flex items-center gap-x-1.5 rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<svg v-if="isInitiatingCall" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span v-if="isInitiatingCall">Calling...</span>
|
||||
<span v-else>Call</span>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="initiateCall(destinationHash)" :disabled="isInitiatingCall" type="button" :class="[ isInitiatingCall ? 'bg-gray-400 focus-visible:outline-gray-500' : 'bg-green-500 hover:bg-green-400 focus-visible:outline-green-500' ]" class="my-auto inline-flex items-center gap-x-1 rounded-md p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<span v-if="isInitiatingCall">
|
||||
<span>Calling...</span>
|
||||
</span>
|
||||
<span v-else>Initiate Call</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex p-1 dark:bg-zinc-700 dark:border-zinc-600">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-zinc-800 bg-gray-50 dark:bg-zinc-900/50">
|
||||
<div>
|
||||
<div class='dark:text-white'>My Destination Hash</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-100">{{ myAudioCallAddressHash || "Unknown" }}</div>
|
||||
</div>
|
||||
<div class="ml-auto my-auto mr-1">
|
||||
<a @click="announce" href="javascript:void(0)" class="rounded-full">
|
||||
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded-full">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto mx-1 text-sm">Announce</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">My Destination Hash</div>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono mt-0.5">{{ myAudioCallAddressHash || "Unknown" }}</div>
|
||||
</div>
|
||||
<button @click="announce" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z" />
|
||||
</svg>
|
||||
<span>Announce</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- active calls -->
|
||||
<div v-if="activeAudioCalls.length > 0" class="border rounded-xl bg-white shadow w-full overflow-hidden dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100">
|
||||
<div class="flex border-b border-gray-300 text-gray-700 p-2 dark:text-zinc-100">
|
||||
<div v-if="activeAudioCalls.length > 0" class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-lg w-full overflow-hidden">
|
||||
<div class="flex items-center border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm px-4 py-3">
|
||||
<div class="my-auto mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto">Active Calls</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-zinc-100">Active Calls</div>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<div v-for="audioCall in activeAudioCalls" class="flex p-2">
|
||||
<div class="mr-2 my-auto">
|
||||
<div class="bg-gray-100 p-2 rounded-full">
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
<div v-for="audioCall in activeAudioCalls" class="flex items-center p-4 hover:bg-gray-50 dark:hover:bg-zinc-900/50 transition-colors">
|
||||
<div class="mr-3 flex-shrink-0">
|
||||
<div class="bg-gray-100 dark:bg-zinc-800 p-2.5 rounded-xl">
|
||||
<svg v-if="audioCall.is_outbound" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path d="M3.5 2A1.5 1.5 0 0 0 2 3.5V5c0 1.149.15 2.263.43 3.326a13.022 13.022 0 0 0 9.244 9.244c1.063.28 2.177.43 3.326.43h1.5a1.5 1.5 0 0 0 1.5-1.5v-1.148a1.5 1.5 0 0 0-1.175-1.465l-3.223-.716a1.5 1.5 0 0 0-1.767 1.052l-.267.933c-.117.41-.555.643-.95.48a11.542 11.542 0 0 1-6.254-6.254c-.163-.395.07-.833.48-.95l.933-.267a1.5 1.5 0 0 0 1.052-1.767l-.716-3.223A1.5 1.5 0 0 0 4.648 2H3.5ZM16.5 4.56l-3.22 3.22a.75.75 0 1 1-1.06-1.06l3.22-3.22h-2.69a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0V4.56Z" />
|
||||
</svg>
|
||||
@@ -178,24 +175,24 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>{{ audioCall.remote_destination_hash || "Unknown" }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-zinc-100">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-900 dark:text-zinc-100 truncate">{{ audioCall.remote_destination_hash || "Unknown" }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-zinc-400">
|
||||
<span v-if="audioCall.is_outbound">Outgoing Call...</span>
|
||||
<span v-else>Incoming Call...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2 ml-auto my-auto mx-2">
|
||||
<div class="flex items-center gap-2 ml-auto flex-shrink-0">
|
||||
|
||||
<!-- rejoin call -->
|
||||
<button v-if="audioCall.is_active" title="Join Call" @click="joinCall(audioCall.hash)" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full bg-green-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-green-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-500">
|
||||
<button v-if="audioCall.is_active" title="Join Call" @click="joinCall(audioCall.hash)" type="button" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- hangup call -->
|
||||
<button v-if="audioCall.is_active" title="Hangup Call" @click="hangupCall(audioCall.hash)" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full bg-red-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
<button v-if="audioCall.is_active" title="Hangup Call" @click="hangupCall(audioCall.hash)" type="button" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 rotate-[135deg] translate-y-0.5">
|
||||
<path fill-rule="evenodd" d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@@ -207,24 +204,22 @@
|
||||
</div>
|
||||
|
||||
<!-- call history -->
|
||||
<div v-if="inactiveAudioCalls.length > 0" class="border rounded-xl bg-white shadow w-full overflow-hidden dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100">
|
||||
<div class="flex border-b border-gray-300 text-gray-700 p-2">
|
||||
<div v-if="inactiveAudioCalls.length > 0" class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-lg w-full overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm px-4 py-3">
|
||||
<div class="my-auto mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto">Call History</div>
|
||||
<div class="ml-auto">
|
||||
<button @click="clearCallHistory" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-zinc-100">Call History</div>
|
||||
<button @click="clearCallHistory" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 px-3 py-1.5 text-xs font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<div v-for="audioCall in inactiveAudioCalls" class="group flex p-2">
|
||||
<div class="mr-2 my-auto">
|
||||
<div class="bg-gray-100 p-2 rounded-full">
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
<div v-for="audioCall in inactiveAudioCalls" class="group flex items-center p-4 hover:bg-gray-50 dark:hover:bg-zinc-900/50 transition-colors">
|
||||
<div class="mr-3 flex-shrink-0">
|
||||
<div class="bg-gray-100 dark:bg-zinc-800 p-2.5 rounded-xl">
|
||||
<svg v-if="audioCall.is_outbound" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path d="M3.5 2A1.5 1.5 0 0 0 2 3.5V5c0 1.149.15 2.263.43 3.326a13.022 13.022 0 0 0 9.244 9.244c1.063.28 2.177.43 3.326.43h1.5a1.5 1.5 0 0 0 1.5-1.5v-1.148a1.5 1.5 0 0 0-1.175-1.465l-3.223-.716a1.5 1.5 0 0 0-1.767 1.052l-.267.933c-.117.41-.555.643-.95.48a11.542 11.542 0 0 1-6.254-6.254c-.163-.395.07-.833.48-.95l.933-.267a1.5 1.5 0 0 0 1.052-1.767l-.716-3.223A1.5 1.5 0 0 0 4.648 2H3.5ZM16.5 4.56l-3.22 3.22a.75.75 0 1 1-1.06-1.06l3.22-3.22h-2.69a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0V4.56Z" />
|
||||
</svg>
|
||||
@@ -233,14 +228,14 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Destination: {{ audioCall.remote_destination_hash || "Unknown" }}</div>
|
||||
<div class="text-sm text-gray-500">Call Hash: {{ audioCall.hash }}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-900 dark:text-zinc-100 truncate">{{ audioCall.remote_destination_hash || "Unknown" }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-zinc-400 font-mono">Call Hash: {{ audioCall.hash }}</div>
|
||||
</div>
|
||||
<div class="hidden group-hover:flex space-x-2 ml-auto my-auto mx-2">
|
||||
<div class="hidden group-hover:flex items-center gap-2 ml-auto flex-shrink-0">
|
||||
|
||||
<!-- delete call -->
|
||||
<button @click="deleteCall(audioCall.hash)" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full bg-gray-100 p-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
|
||||
<button @click="deleteCall(audioCall.hash)" type="button" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 px-3 py-2 text-sm font-semibold text-gray-700 dark:text-zinc-300 shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||
</svg>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto p-2 space-y-2">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900">
|
||||
<div class="overflow-y-auto p-3 md:p-6 space-y-4 max-w-5xl mx-auto w-full">
|
||||
|
||||
<!-- community interfaces -->
|
||||
<div v-if="!isEditingInterface && config != null && config.show_suggested_community_interfaces" class="bg-white rounded shadow divide-y divide-gray-200 dark:bg-zinc-900">
|
||||
<div class="flex p-2">
|
||||
<div v-if="!isEditingInterface && config != null && config.show_suggested_community_interfaces" class="bg-white/95 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
<div class="flex p-3">
|
||||
<div class="my-auto mr-auto">
|
||||
<div class="font-bold dark:text-white">Community Interfaces</div>
|
||||
<div class="text-sm dark:text-gray-100">These TCP interfaces serve as a quick way to test Reticulum. We suggest running your own as these may not always be available.</div>
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Quick start</div>
|
||||
<div class="font-semibold text-lg text-gray-900 dark:text-white">Community Interfaces</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-200">One-click helpers for public TCP relays. Spin up your own when possible to ensure availability.</div>
|
||||
</div>
|
||||
<div class="my-auto ml-2">
|
||||
<button @click="updateConfig({'show_suggested_community_interfaces': false})" type="button" class="text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||
<button @click="updateConfig({'show_suggested_community_interfaces': false})" type="button" class="text-gray-700 bg-white border border-gray-200 hover:border-red-300 p-2 rounded-full shadow-sm dark:bg-zinc-800 dark:text-white dark:border-zinc-700 dark:hover:border-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"/>
|
||||
</svg>
|
||||
@@ -19,31 +20,31 @@
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200 dark:text-white">
|
||||
|
||||
<div class="flex px-2 py-1">
|
||||
<div class="flex px-3 py-2 items-center">
|
||||
<div class="my-auto mr-auto">
|
||||
<div>RNS Testnet Amsterdam</div>
|
||||
<div class="text-xs">amsterdam.connect.reticulum.network:4965</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">RNS Testnet Amsterdam</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">amsterdam.connect.reticulum.network:4965</div>
|
||||
</div>
|
||||
<div class="ml-2 my-auto">
|
||||
<button
|
||||
@click="newInterfaceName='RNS Testnet Amsterdam';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='amsterdam.connect.reticulum.network';newInterfaceTargetPort='4965'"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
|
||||
class="inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
<span>Use Interface</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex px-2 py-1">
|
||||
<div class="flex px-3 py-2 items-center">
|
||||
<div class="my-auto mr-auto">
|
||||
<div>RNS Testnet BetweenTheBorders</div>
|
||||
<div class="text-xs">reticulum.betweentheborders.com:4242</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">RNS Testnet BetweenTheBorders</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">reticulum.betweentheborders.com:4242</div>
|
||||
</div>
|
||||
<div class="ml-2 my-auto">
|
||||
<button
|
||||
@click="newInterfaceName='RNS Testnet BetweenTheBorders';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='reticulum.betweentheborders.com';newInterfaceTargetPort='4242'"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
|
||||
class="inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
<span>Use Interface</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -53,44 +54,64 @@
|
||||
</div>
|
||||
|
||||
<!-- add interface form -->
|
||||
<div class="bg-white rounded shadow divide-y divide-gray-300 dark:divide-zinc-700 dark:bg-zinc-900">
|
||||
<div class="p-2 font-bold dark:text-white">
|
||||
<span v-if="isEditingInterface">Edit Interface</span>
|
||||
<span v-else>Add Interface</span>
|
||||
<div class="bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl">
|
||||
<div class="flex flex-wrap gap-3 items-center p-3 border-b border-gray-200 dark:border-zinc-800">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ isEditingInterface ? 'Update' : 'Create' }}</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ isEditingInterface ? 'Edit Interface' : 'Add Interface' }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Name your connection and select its transport type.</div>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="loadComports" type="button" class="secondary-chip text-xs">
|
||||
Reload Ports
|
||||
</button>
|
||||
<RouterLink :to="{ name: 'interfaces' }" class="secondary-chip text-xs">
|
||||
View All
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 space-y-3">
|
||||
<div class="p-3 md:p-5 space-y-4">
|
||||
|
||||
<!-- iGeneric interface settings -->
|
||||
<!-- interface name -->
|
||||
<div>
|
||||
<FormLabel class="mb-1">Name</FormLabel>
|
||||
<FormLabel class="glass-label">Name</FormLabel>
|
||||
<input type="text" :disabled="isEditingInterface" placeholder="New Interface Name"
|
||||
v-model="newInterfaceName"
|
||||
class="border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white"
|
||||
:class="[ isEditingInterface ? 'cursor-not-allowed bg-gray-200' : 'bg-gray-50' ]">
|
||||
<FormSubLabel>Interface names must be unique.</FormSubLabel>
|
||||
class="input-field"
|
||||
:class="[ isEditingInterface ? 'cursor-not-allowed bg-gray-200 dark:bg-zinc-800' : '' ]">
|
||||
<FormSubLabel class="text-xs">Interface names must be unique.</FormSubLabel>
|
||||
</div>
|
||||
|
||||
<!-- interface type -->
|
||||
<div class="mb-2">
|
||||
<FormLabel class="mb-1">Type</FormLabel>
|
||||
<select v-model="newInterfaceType" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white">
|
||||
<option disabled selected>--</option>
|
||||
<option value="AutoInterface">Auto Interface</option>
|
||||
<option disabled selected>RNodes</option>
|
||||
<option value="RNodeInterface">RNode Interface</option>
|
||||
<option value="RNodeMultiInterface">RNode Multi Interface</option>
|
||||
<option disabled selected>IP Networks</option>
|
||||
<option value="TCPClientInterface">TCP Client Interface</option>
|
||||
<option value="TCPServerInterface">TCP Server Interface</option>
|
||||
<option value="UDPInterface">UDP Interface</option>
|
||||
<option value="I2PInterface">I2P Interface</option>
|
||||
<option disabled selected>Hardware</option>
|
||||
<option value="SerialInterface">Serial Interface</option>
|
||||
<option value="KISSInterface">KISS Interface</option>
|
||||
<option hidden value="AX25KISSInterface">AX.25 KISS Interface</option>
|
||||
<option disabled selected>Other</option>
|
||||
<option value="PipeInterface">Pipe Interface</option>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Type</FormLabel>
|
||||
<select v-model="newInterfaceType" class="input-field">
|
||||
<option disabled selected>Pick a category…</option>
|
||||
<optgroup label="Automatic">
|
||||
<option value="AutoInterface">Auto Interface</option>
|
||||
</optgroup>
|
||||
<optgroup label="RNodes">
|
||||
<option value="RNodeInterface">RNode Interface</option>
|
||||
<option value="RNodeMultiInterface">RNode Multi Interface</option>
|
||||
</optgroup>
|
||||
<optgroup label="IP Networks">
|
||||
<option value="TCPClientInterface">TCP Client Interface</option>
|
||||
<option value="TCPServerInterface">TCP Server Interface</option>
|
||||
<option value="UDPInterface">UDP Interface</option>
|
||||
<option value="I2PInterface">I2P Interface</option>
|
||||
</optgroup>
|
||||
<optgroup label="Hardware">
|
||||
<option value="SerialInterface">Serial Interface</option>
|
||||
<option value="KISSInterface">KISS Interface</option>
|
||||
<option value="AX25KISSInterface">AX.25 KISS Interface</option>
|
||||
</optgroup>
|
||||
<optgroup label="Pipelines">
|
||||
<option value="PipeInterface">Pipe Interface</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<FormSubLabel>
|
||||
Need help? <a class="text-blue-500 underline" href="https://reticulum.network/manual/interfaces.html" target="_blank">Reticulum Docs: Configuring Interfaces</a>
|
||||
@@ -101,13 +122,13 @@
|
||||
<!-- interface target host -->
|
||||
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
|
||||
<FormLabel class="mb-1">Target Host</FormLabel>
|
||||
<input type="text" placeholder="e.g: example.com" v-model="newInterfaceTargetHost" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white dark:focus:ring-blue-600 dark:focus:border-blue-600">
|
||||
<input type="text" placeholder="e.g: example.com" v-model="newInterfaceTargetHost" class="input-field">
|
||||
</div>
|
||||
|
||||
<!-- interface target port -->
|
||||
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
|
||||
<FormLabel class="mb-1">Target Port</FormLabel>
|
||||
<input type="text" placeholder="e.g: 1234" v-model="newInterfaceTargetPort" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white dark:focus:ring-blue-600 dark:focus:border-blue-600">
|
||||
<input type="text" placeholder="e.g: 1234" v-model="newInterfaceTargetPort" class="input-field">
|
||||
</div>
|
||||
|
||||
<!-- TCPServerInterface -->
|
||||
@@ -1317,3 +1338,24 @@ export default {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.glass-card {
|
||||
@apply bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl;
|
||||
}
|
||||
.input-field {
|
||||
@apply bg-gray-50/90 dark:bg-zinc-900/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-2xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-2.5 text-gray-900 dark:text-gray-100 transition;
|
||||
}
|
||||
.glass-label {
|
||||
@apply mb-1 text-sm font-semibold text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
.primary-chip {
|
||||
@apply inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
|
||||
}
|
||||
.secondary-chip {
|
||||
@apply inline-flex items-center gap-x-2 rounded-full border border-gray-300 dark:border-zinc-700 px-3 py-1.5 text-xs font-semibold text-gray-700 dark:text-gray-100 bg-white/80 dark:bg-zinc-900/70 hover:border-blue-400;
|
||||
}
|
||||
.glass-field {
|
||||
@apply space-y-1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -125,9 +125,10 @@ export default {
|
||||
this.importableInterfaces = [];
|
||||
this.selectedInterfaces = [];
|
||||
},
|
||||
dismiss() {
|
||||
dismiss(result = false) {
|
||||
this.isShowing = false;
|
||||
this.$emit("dismissed");
|
||||
const imported = result === true;
|
||||
this.$emit("dismissed", imported);
|
||||
},
|
||||
clearSelectedFile() {
|
||||
this.selectedFile = null;
|
||||
@@ -221,7 +222,7 @@ export default {
|
||||
});
|
||||
|
||||
// dismiss modal
|
||||
this.dismiss();
|
||||
this.dismiss(true);
|
||||
|
||||
// tell user interfaces were imported
|
||||
DialogUtils.alert("Interfaces imported successfully. MeshChat must be restarted for these changes to take effect.");
|
||||
|
||||
@@ -1,207 +1,117 @@
|
||||
<template>
|
||||
<div class="border rounded bg-white shadow dark:bg-zinc-800 dark:border-zinc-700">
|
||||
|
||||
<!-- IFAC info -->
|
||||
<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">
|
||||
<div class="flex text-sm">
|
||||
<div class="my-auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4 text-green-500">
|
||||
<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" />
|
||||
</svg>
|
||||
<div class="interface-card">
|
||||
<div class="flex gap-4 items-start">
|
||||
<div class="interface-card__icon">
|
||||
<MaterialDesignIcon :icon-name="iconName" class="w-6 h-6"/>
|
||||
</div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ iface._name }}</div>
|
||||
<span class="type-chip">{{ iface.type }}</span>
|
||||
<span :class="statusChipClass">{{ isInterfaceEnabled(iface) ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
<span class="ml-1 my-auto">
|
||||
<span class="text-green-500">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span> <span v-if="iface._stats?.ifac_netname != null">• Network Name: <span class="text-purple-500">{{ iface._stats.ifac_netname }}</span></span> • Signature <span @click="onIFACSignatureClick(iface._stats.ifac_signature)" class="cursor-pointer"><{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex py-2">
|
||||
|
||||
<!-- icon -->
|
||||
<div class="my-auto mx-2">
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</svg>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</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-if="iface.type === 'RNodeMultiInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><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></svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'I2PInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M72,92A12,12,0,1,1,60,80,12,12,0,0,1,72,92Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,128,80Zm68,24a12,12,0,1,0-12-12A12,12,0,0,0,196,104ZM60,152a12,12,0,1,0,12,12A12,12,0,0,0,60,152Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,128,152Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,196,152Z"></path></svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'KISSInterface' || iface.type === 'AX25KISSInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M104,168a8,8,0,0,1-8,8H64a8,8,0,0,1,0-16H96A8,8,0,0,1,104,168Zm-8-40H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16Zm0-32H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16ZM232,80V192a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V72a8,8,0,0,1,5.7-7.66l160-48a8,8,0,0,1,4.6,15.33L86.51,64H216A16,16,0,0,1,232,80ZM216,192V80H40V192H216Zm-16-56a40,40,0,1,1-40-40A40,40,0,0,1,200,136Zm-16,0a24,24,0,1,0-24,24A24,24,0,0,0,184,136Z"></path></svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'PipeInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M128,128a8,8,0,0,1-3,6.25l-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32A8,8,0,0,1,128,128Zm48,24H136a8,8,0,0,0,0,16h40a8,8,0,0,0,0-16Zm56-96V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56ZM216,200V56H40V200H216Z"></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 -->
|
||||
<div v-if="iface.type === 'AutoInterface'">
|
||||
{{ iface.type }} • Ethernet and WiFi
|
||||
</div>
|
||||
|
||||
<!-- tcp client interface -->
|
||||
<div v-else-if="iface.type === 'TCPClientInterface'">
|
||||
{{ iface.type }} • {{ iface.target_host }}:{{ iface.target_port }}
|
||||
</div>
|
||||
|
||||
<!-- tcp server interface -->
|
||||
<div v-else-if="iface.type === 'TCPServerInterface'">
|
||||
{{ iface.type }} • {{ iface.listen_ip }}:{{ iface.listen_port }}
|
||||
</div>
|
||||
|
||||
<!-- other interface types -->
|
||||
<div v-else>{{ iface.type }}</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ description }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="stat-chip" v-if="iface._stats?.bitrate">Bitrate {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</span>
|
||||
<span class="stat-chip">TX {{ formatBytes(iface._stats?.txb ?? 0) }}</span>
|
||||
<span class="stat-chip">RX {{ formatBytes(iface._stats?.rxb ?? 0) }}</span>
|
||||
<span class="stat-chip" v-if="iface.type === 'RNodeInterface' && iface._stats?.noise_floor">Noise {{ iface._stats?.noise_floor }} dBm</span>
|
||||
<span class="stat-chip" v-if="iface._stats?.clients != null">Clients {{ iface._stats?.clients }}</span>
|
||||
</div>
|
||||
<div v-if="iface._stats?.ifac_signature" class="ifac-line">
|
||||
<span class="text-emerald-500 font-semibold">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span>
|
||||
<span v-if="iface._stats?.ifac_netname">• {{ iface._stats.ifac_netname }}</span>
|
||||
<span>•</span>
|
||||
<button @click="onIFACSignatureClick(iface._stats.ifac_signature)" type="button" class="text-blue-500 hover:underline">
|
||||
{{ iface._stats.ifac_signature.slice(0, 8) }}…{{ iface._stats.ifac_signature.slice(-8) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- enabled state badge -->
|
||||
<div class="ml-auto my-auto mr-2">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- enable/disable interface button -->
|
||||
<div class="my-auto mr-1">
|
||||
<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>
|
||||
<div class="flex flex-col gap-2 items-end">
|
||||
<button
|
||||
v-if="isInterfaceEnabled(iface)"
|
||||
@click="disableInterface"
|
||||
type="button"
|
||||
class="secondary-chip text-xs"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="power" class="w-4 h-4"/>
|
||||
Disable
|
||||
</button>
|
||||
<button v-else @click="enableInterface" type="button" class="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
|
||||
v-else
|
||||
@click="enableInterface"
|
||||
type="button"
|
||||
class="primary-chip text-xs"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="power" class="w-4 h-4"/>
|
||||
Enable
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="my-auto mr-2">
|
||||
<DropDownMenu>
|
||||
<template v-slot:button>
|
||||
<template #button>
|
||||
<IconButton>
|
||||
<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="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
|
||||
</svg>
|
||||
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5"/>
|
||||
</IconButton>
|
||||
</template>
|
||||
<template v-slot:items>
|
||||
|
||||
<!-- enable/disable interface button -->
|
||||
<div class="border-b dark:border-zinc-700">
|
||||
|
||||
<!-- enable interface button -->
|
||||
<DropDownMenuItem v-if="isInterfaceEnabled(iface)" @click="disableInterface">
|
||||
<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.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Disable Interface</span>
|
||||
<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 v-else @click="enableInterface">
|
||||
<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.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Enable Interface</span>
|
||||
<DropDownMenuItem @click="exportInterface">
|
||||
<MaterialDesignIcon icon-name="export" class="w-5 h-5"/>
|
||||
<span>Export Interface</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- edit interface button -->
|
||||
<DropDownMenuItem @click="editInterface">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path d="M21.731 2.269a2.625 2.625 0 0 0-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 0 0 0-3.712ZM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 0 0-1.32 2.214l-.8 2.685a.75.75 0 0 0 .933.933l2.685-.8a5.25 5.25 0 0 0 2.214-1.32L19.513 8.2Z" />
|
||||
</svg>
|
||||
<span>Edit Interface</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- export interface button -->
|
||||
<DropDownMenuItem @click="exportInterface">
|
||||
<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.25a.75.75 0 0 1 .75.75v11.69l3.22-3.22a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 1 1 1.06-1.06l3.22 3.22V3a.75.75 0 0 1 .75-.75Zm-9 13.5a.75.75 0 0 1 .75.75v2.25a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5V16.5a.75.75 0 0 1 1.5 0v2.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V16.5a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Export Interface</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- delete interface button -->
|
||||
<div class="border-t dark:border-zinc-700">
|
||||
<DropDownMenuItem @click="deleteInterface">
|
||||
<svg class="size-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<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>
|
||||
|
||||
<!-- extra interface details -->
|
||||
<div v-if="['UDPInterface', 'RNodeInterface'].includes(iface.type)" class="p-1 text-sm border-t dark:text-zinc-100 dark:border-zinc-700">
|
||||
|
||||
<!-- udp interface -->
|
||||
<div v-if="iface.type === 'UDPInterface'">
|
||||
<div>Listen: {{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||
<div>Forward: {{ iface.forward_ip }}:{{ iface.forward_port }}</div>
|
||||
<div v-if="['UDPInterface', 'RNodeInterface'].includes(iface.type)" class="mt-4 grid gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div v-if="iface.type === 'UDPInterface'" class="detail-grid">
|
||||
<div>
|
||||
<div class="detail-label">Listen</div>
|
||||
<div class="detail-value">{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">Forward</div>
|
||||
<div class="detail-value">{{ iface.forward_ip }}:{{ iface.forward_port }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- rnode interface details -->
|
||||
<div v-else-if="iface.type === 'RNodeInterface'">
|
||||
<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 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 class="flex bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-t rounded-b dark:bg-zinc-800 dark:text-white dark:border-zinc-700">
|
||||
|
||||
<!-- status -->
|
||||
<div v-if="iface._stats?.status === true" class="text-sm text-green-500">Connected</div>
|
||||
<div v-else class="text-sm text-red-500">Disconnected</div>
|
||||
|
||||
<!-- stats -->
|
||||
<div>• Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</div>
|
||||
<div>• TX: {{ formatBytes(iface._stats?.txb ?? 0) }}</div>
|
||||
<div>• RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}</div>
|
||||
<div v-if="iface.type === 'RNodeInterface'">• Noise Floor: {{
|
||||
iface._stats?.noise_floor
|
||||
}} dBm
|
||||
</div>
|
||||
<div v-if="iface._stats?.clients != null">• Clients: {{ iface._stats?.clients }}</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -211,6 +121,7 @@ import Utils from "../../js/Utils";
|
||||
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||
import IconButton from "../IconButton.vue";
|
||||
import DropDownMenu from "../DropDownMenu.vue";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: 'Interface',
|
||||
@@ -218,6 +129,7 @@ export default {
|
||||
DropDownMenu,
|
||||
IconButton,
|
||||
DropDownMenuItem,
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
iface: Object,
|
||||
@@ -259,5 +171,81 @@ export default {
|
||||
return Utils.formatFrequency(hz);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
iconName() {
|
||||
switch (this.iface.type) {
|
||||
case "AutoInterface":
|
||||
return "home-automation";
|
||||
case "RNodeInterface":
|
||||
return "radio-tower";
|
||||
case "RNodeMultiInterface":
|
||||
return "access-point-network";
|
||||
case "TCPClientInterface":
|
||||
return "lan-connect";
|
||||
case "TCPServerInterface":
|
||||
return "lan";
|
||||
case "UDPInterface":
|
||||
return "wan";
|
||||
case "SerialInterface":
|
||||
return "usb-port";
|
||||
case "KISSInterface":
|
||||
case "AX25KISSInterface":
|
||||
return "antenna";
|
||||
case "I2PInterface":
|
||||
return "eye";
|
||||
case "PipeInterface":
|
||||
return "pipe";
|
||||
default:
|
||||
return "server-network";
|
||||
}
|
||||
},
|
||||
description() {
|
||||
if (this.iface.type === "TCPClientInterface") {
|
||||
return `${this.iface.target_host}:${this.iface.target_port}`;
|
||||
}
|
||||
if (this.iface.type === "TCPServerInterface" || this.iface.type === "UDPInterface") {
|
||||
return `${this.iface.listen_ip}:${this.iface.listen_port}`;
|
||||
}
|
||||
if (this.iface.type === "SerialInterface") {
|
||||
return `${this.iface.port} @ ${this.iface.speed || "9600"}bps`;
|
||||
}
|
||||
if (this.iface.type === "AutoInterface") {
|
||||
return "Auto-detect Ethernet and Wi-Fi peers";
|
||||
}
|
||||
return this.iface.description || "Custom interface";
|
||||
},
|
||||
statusChipClass() {
|
||||
return this.isInterfaceEnabled(this.iface)
|
||||
? "inline-flex items-center rounded-full bg-green-100 text-green-700 px-2 py-0.5 text-xs font-semibold"
|
||||
: "inline-flex items-center rounded-full bg-red-100 text-red-700 px-2 py-0.5 text-xs font-semibold";
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.interface-card {
|
||||
@apply bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg p-4 space-y-3;
|
||||
}
|
||||
.interface-card__icon {
|
||||
@apply w-12 h-12 rounded-2xl bg-blue-50 text-blue-600 dark:bg-blue-900/40 dark:text-blue-200 flex items-center justify-center;
|
||||
}
|
||||
.type-chip {
|
||||
@apply inline-flex items-center rounded-full bg-gray-100 dark:bg-zinc-800 px-2 py-0.5 text-xs font-semibold text-gray-600 dark:text-gray-200;
|
||||
}
|
||||
.stat-chip {
|
||||
@apply inline-flex items-center rounded-full border border-gray-200 dark:border-zinc-700 px-2 py-0.5;
|
||||
}
|
||||
.ifac-line {
|
||||
@apply text-xs flex flex-wrap items-center gap-1;
|
||||
}
|
||||
.detail-grid {
|
||||
@apply grid gap-3 sm:grid-cols-2;
|
||||
}
|
||||
.detail-label {
|
||||
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
.detail-value {
|
||||
@apply text-sm font-medium text-gray-900 dark:text-white;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,85 +1,87 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto p-2 space-y-2">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900">
|
||||
<div class="overflow-y-auto p-3 md:p-6 space-y-4 max-w-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 class="my-auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="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" />
|
||||
</svg>
|
||||
<div v-if="showRestartReminder" class="bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-3xl shadow-xl p-4 flex flex-wrap gap-3 items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<MaterialDesignIcon icon-name="alert" class="w-6 h-6"/>
|
||||
<div>
|
||||
<div class="text-lg font-semibold">Restart required</div>
|
||||
<div class="text-sm">Reticulum MeshChat must be restarted for any interface changes to take effect.</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 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 v-if="isElectron" @click="relaunch" type="button" class="ml-auto inline-flex items-center gap-2 rounded-full border border-white/40 px-4 py-1.5 text-sm font-semibold text-white hover:bg-white/10 transition">
|
||||
<MaterialDesignIcon icon-name="restart" class="w-4 h-4"/>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1">
|
||||
|
||||
<!-- Add Interface button -->
|
||||
<RouterLink :to="{ name: 'interfaces.add' }">
|
||||
<button 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-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="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span>Add Interface</span>
|
||||
</button>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Import button -->
|
||||
<div class="my-auto">
|
||||
<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">
|
||||
<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="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" />
|
||||
</svg>
|
||||
<span>Import</span>
|
||||
</button>
|
||||
<div class="glass-card space-y-4">
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Manage</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Search, filter and export your Reticulum adapters.</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
|
||||
<MaterialDesignIcon icon-name="plus" class="w-4 h-4"/>
|
||||
Add Interface
|
||||
</RouterLink>
|
||||
<button @click="showImportInterfacesModal" type="button" class="secondary-chip text-sm">
|
||||
<MaterialDesignIcon icon-name="import" class="w-4 h-4"/>
|
||||
Import
|
||||
</button>
|
||||
<button @click="exportInterfaces" type="button" class="secondary-chip text-sm">
|
||||
<MaterialDesignIcon icon-name="export" class="w-4 h-4"/>
|
||||
Export all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export button -->
|
||||
<div class="my-auto">
|
||||
<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">
|
||||
<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="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" />
|
||||
</svg>
|
||||
<span>Export</span>
|
||||
</button>
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
placeholder="Search by name, type, host..."
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button type="button" @click="setStatusFilter('all')" :class="filterChipClass(statusFilter === 'all')">All</button>
|
||||
<button type="button" @click="setStatusFilter('enabled')" :class="filterChipClass(statusFilter === 'enabled')">Enabled</button>
|
||||
<button type="button" @click="setStatusFilter('disabled')" :class="filterChipClass(statusFilter === 'disabled')">Disabled</button>
|
||||
</div>
|
||||
<div class="w-full sm:w-60">
|
||||
<select v-model="typeFilter" class="input-field">
|
||||
<option value="all">All types</option>
|
||||
<option v-for="type in sortedInterfaceTypes" :key="type" :value="type">{{ type }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- enabled interfaces -->
|
||||
<Interface
|
||||
v-for="iface of enabledInterfaces"
|
||||
:iface="iface"
|
||||
@enable="enableInterface(iface._name)"
|
||||
@disable="disableInterface(iface._name)"
|
||||
@edit="editInterface(iface._name)"
|
||||
@export="exportInterface(iface._name)"
|
||||
@delete="deleteInterface(iface._name)"/>
|
||||
|
||||
<!-- disabled interfaces -->
|
||||
<div v-if="disabledInterfaces.length > 0" class="font-semibold dark:text-zinc-200">Disabled Interfaces</div>
|
||||
<Interface
|
||||
v-for="iface of disabledInterfaces"
|
||||
:iface="iface"
|
||||
@enable="enableInterface(iface._name)"
|
||||
@disable="disableInterface(iface._name)"
|
||||
@edit="editInterface(iface._name)"
|
||||
@export="exportInterface(iface._name)"
|
||||
@delete="deleteInterface(iface._name)"/>
|
||||
<div v-if="filteredInterfaces.length === 0" class="glass-card text-center py-10 text-gray-500 dark:text-gray-300">
|
||||
<MaterialDesignIcon icon-name="lan-disconnect" class="w-10 h-10 mx-auto mb-3"/>
|
||||
<div class="text-lg font-semibold">No interfaces found</div>
|
||||
<div class="text-sm">Adjust your search or add a new interface.</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 xl:grid-cols-2">
|
||||
<Interface
|
||||
v-for="iface of filteredInterfaces"
|
||||
:key="iface._name"
|
||||
:iface="iface"
|
||||
@enable="enableInterface(iface._name)"
|
||||
@disable="disableInterface(iface._name)"
|
||||
@edit="editInterface(iface._name)"
|
||||
@export="exportInterface(iface._name)"
|
||||
@delete="deleteInterface(iface._name)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Dialog -->
|
||||
<ImportInterfacesModal ref="import-interfaces-modal" @dismissed="onImportInterfacesModalDismissed"/>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -89,18 +91,24 @@ import Interface from "./Interface.vue";
|
||||
import Utils from "../../js/Utils";
|
||||
import ImportInterfacesModal from "./ImportInterfacesModal.vue";
|
||||
import DownloadUtils from "../../js/DownloadUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: 'InterfacesPage',
|
||||
components: {
|
||||
ImportInterfacesModal,
|
||||
Interface,
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
interfaces: {},
|
||||
interfaceStats: {},
|
||||
reloadInterval: null,
|
||||
searchTerm: "",
|
||||
statusFilter: "all",
|
||||
typeFilter: "all",
|
||||
hasPendingInterfaceChanges: false,
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
@@ -121,6 +129,9 @@ export default {
|
||||
relaunch() {
|
||||
ElectronUtils.relaunch();
|
||||
},
|
||||
trackInterfaceChange() {
|
||||
this.hasPendingInterfaceChanges = true;
|
||||
},
|
||||
isInterfaceEnabled: function(iface) {
|
||||
return Utils.isInterfaceEnabled(iface);
|
||||
},
|
||||
@@ -155,6 +166,7 @@ export default {
|
||||
await window.axios.post(`/api/v1/reticulum/interfaces/enable`, {
|
||||
name: interfaceName,
|
||||
});
|
||||
this.trackInterfaceChange();
|
||||
} catch(e) {
|
||||
DialogUtils.alert("failed to enable interface");
|
||||
console.log(e);
|
||||
@@ -171,6 +183,7 @@ export default {
|
||||
await window.axios.post(`/api/v1/reticulum/interfaces/disable`, {
|
||||
name: interfaceName,
|
||||
});
|
||||
this.trackInterfaceChange();
|
||||
} catch(e) {
|
||||
DialogUtils.alert("failed to disable interface");
|
||||
console.log(e);
|
||||
@@ -200,6 +213,7 @@ export default {
|
||||
await window.axios.post(`/api/v1/reticulum/interfaces/delete`, {
|
||||
name: interfaceName,
|
||||
});
|
||||
this.trackInterfaceChange();
|
||||
} catch(e) {
|
||||
DialogUtils.alert("failed to delete interface");
|
||||
console.log(e);
|
||||
@@ -214,6 +228,7 @@ export default {
|
||||
|
||||
// fetch exported interfaces
|
||||
const response = await window.axios.post('/api/v1/reticulum/interfaces/export');
|
||||
this.trackInterfaceChange();
|
||||
|
||||
// download file to browser
|
||||
DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
|
||||
@@ -232,6 +247,7 @@ export default {
|
||||
interfaceName,
|
||||
],
|
||||
});
|
||||
this.trackInterfaceChange();
|
||||
|
||||
// download file to browser
|
||||
DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
|
||||
@@ -244,15 +260,29 @@ export default {
|
||||
showImportInterfacesModal() {
|
||||
this.$refs["import-interfaces-modal"].show();
|
||||
},
|
||||
onImportInterfacesModalDismissed() {
|
||||
onImportInterfacesModalDismissed(imported = false) {
|
||||
// reload interfaces as something may have been imported
|
||||
this.loadInterfaces();
|
||||
if(imported){
|
||||
this.trackInterfaceChange();
|
||||
}
|
||||
},
|
||||
setStatusFilter(value) {
|
||||
this.statusFilter = value;
|
||||
},
|
||||
filterChipClass(isActive) {
|
||||
return isActive
|
||||
? "primary-chip text-xs"
|
||||
: "secondary-chip text-xs";
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isElectron() {
|
||||
return ElectronUtils.isElectron();
|
||||
},
|
||||
showRestartReminder() {
|
||||
return this.hasPendingInterfaceChanges;
|
||||
},
|
||||
interfacesWithStats() {
|
||||
const results = [];
|
||||
for(const [interfaceName, iface] of Object.entries(this.interfaces)){
|
||||
@@ -268,6 +298,43 @@ export default {
|
||||
disabledInterfaces() {
|
||||
return this.interfacesWithStats.filter((iface) => !this.isInterfaceEnabled(iface));
|
||||
},
|
||||
filteredInterfaces() {
|
||||
const search = this.searchTerm.toLowerCase().trim();
|
||||
return this.interfacesWithStats
|
||||
.filter((iface) => {
|
||||
if (this.statusFilter === "enabled" && !this.isInterfaceEnabled(iface)) {
|
||||
return false;
|
||||
}
|
||||
if (this.statusFilter === "disabled" && this.isInterfaceEnabled(iface)) {
|
||||
return false;
|
||||
}
|
||||
if (this.typeFilter !== "all" && iface.type !== this.typeFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
iface._name,
|
||||
iface.type,
|
||||
iface.target_host,
|
||||
iface.target_port,
|
||||
iface.listen_ip,
|
||||
iface.listen_port,
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
return haystack.includes(search);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const enabledDiff = Number(this.isInterfaceEnabled(b)) - Number(this.isInterfaceEnabled(a));
|
||||
if (enabledDiff !== 0) return enabledDiff;
|
||||
return a._name.localeCompare(b._name);
|
||||
});
|
||||
},
|
||||
sortedInterfaceTypes() {
|
||||
const types = new Set();
|
||||
this.interfacesWithStats.forEach((iface) => types.add(iface.type));
|
||||
return Array.from(types).sort();
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
<template>
|
||||
<div class="inline-flex rounded-md shadow-sm">
|
||||
<div class="inline-flex">
|
||||
|
||||
<button v-if="isRecordingAudioAttachment" @click="stopRecordingAudioAttachment" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
|
||||
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
|
||||
<path d="M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z" />
|
||||
</svg>
|
||||
<button v-if="isRecordingAudioAttachment" @click="stopRecordingAudioAttachment" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 shadow-sm hover:border-red-400 transition dark:border-red-500/40 dark:bg-red-900/30 dark:text-red-100">
|
||||
<MaterialDesignIcon icon-name="microphone" class="w-4 h-4"/>
|
||||
<span class="ml-1">
|
||||
<slot/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button v-else @click="showMenu" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
|
||||
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
|
||||
<path d="M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z" />
|
||||
</svg>
|
||||
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Voice</span>
|
||||
<button v-else @click="showMenu" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition">
|
||||
<MaterialDesignIcon icon-name="microphone-plus" class="w-4 h-4"/>
|
||||
<span class="hidden xl:inline-block whitespace-nowrap">Add Voice</span>
|
||||
</button>
|
||||
|
||||
<div class="relative block">
|
||||
@@ -27,11 +21,11 @@
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95">
|
||||
<div v-if="isShowingMenu" v-click-outside="hideMenu" class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div v-if="isShowingMenu" v-click-outside="hideMenu" class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none">
|
||||
<div class="py-1">
|
||||
<button @click="startRecordingCodec2('1200')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Low Quality - Codec2 (1200)</button>
|
||||
<button @click="startRecordingCodec2('3200')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Medium Quality - Codec2 (3200)</button>
|
||||
<button @click="startRecordingOpus()" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">High Quality - OPUS</button>
|
||||
<button @click="startRecordingCodec2('1200')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Low Quality - Codec2 (1200)</button>
|
||||
<button @click="startRecordingCodec2('3200')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Medium Quality - Codec2 (3200)</button>
|
||||
<button @click="startRecordingOpus()" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">High Quality - OPUS</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -41,8 +35,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: 'AddAudioButton',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
isRecordingAudioAttachment: Boolean,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<template>
|
||||
<div class="inline-flex rounded-md shadow-sm">
|
||||
<div class="inline-flex">
|
||||
|
||||
<button @click="showMenu" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M1.5 6a2.25 2.25 0 0 1 2.25-2.25h16.5A2.25 2.25 0 0 1 22.5 6v12a2.25 2.25 0 0 1-2.25 2.25H3.75A2.25 2.25 0 0 1 1.5 18V6ZM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0 0 21 18v-1.94l-2.69-2.689a1.5 1.5 0 0 0-2.12 0l-.88.879.97.97a.75.75 0 1 1-1.06 1.06l-5.16-5.159a1.5 1.5 0 0 0-2.12 0L3 16.061Zm10.125-7.81a1.125 1.125 0 1 1 2.25 0 1.125 1.125 0 0 1-2.25 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Image</span>
|
||||
<button @click="showMenu" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition">
|
||||
<MaterialDesignIcon icon-name="image-plus" class="w-4 h-4"/>
|
||||
<span class="hidden xl:inline-block whitespace-nowrap">Add Image</span>
|
||||
</button>
|
||||
|
||||
<div class="relative block">
|
||||
@@ -16,12 +14,12 @@
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95">
|
||||
<div v-if="isShowingMenu" v-click-outside="hideMenu" class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div v-if="isShowingMenu" v-click-outside="hideMenu" class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none">
|
||||
<div class="py-1">
|
||||
<button @click="addImage('low')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Low Quality (320x320)</button>
|
||||
<button @click="addImage('medium')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Medium Quality (640x640)</button>
|
||||
<button @click="addImage('high')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">High Quality (1280x1280)</button>
|
||||
<button @click="addImage('original')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Original Quality</button>
|
||||
<button @click="addImage('low')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Low Quality (320x320)</button>
|
||||
<button @click="addImage('medium')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Medium Quality (640x640)</button>
|
||||
<button @click="addImage('high')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">High Quality (1280x1280)</button>
|
||||
<button @click="addImage('original')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Original Quality</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -36,8 +34,12 @@
|
||||
<script>
|
||||
import Compressor from 'compressorjs';
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: 'AddImageButton',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
emits: [
|
||||
"add-image",
|
||||
],
|
||||
|
||||
@@ -35,6 +35,20 @@
|
||||
<span>Set Custom Display Name</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- block/unblock button -->
|
||||
<div class="border-t">
|
||||
<DropDownMenuItem v-if="!isBlocked" @click="onBlockDestination">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5 text-red-500">
|
||||
<path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3V13.5a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm-1.5 8.25v3a1.5 1.5 0 0 0 3 0v-3a1.5 1.5 0 0 0-3 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-red-500">Block User</span>
|
||||
</DropDownMenuItem>
|
||||
<DropDownMenuItem v-else @click="onUnblockDestination">
|
||||
<MaterialDesignIcon icon-name="check-circle" class="size-5 text-green-500"/>
|
||||
<span class="text-green-500">Unblock User</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
|
||||
<!-- delete message history button -->
|
||||
<div class="border-t">
|
||||
<DropDownMenuItem @click="onDeleteMessageHistory">
|
||||
@@ -53,6 +67,7 @@
|
||||
import DropDownMenu from "../DropDownMenu.vue";
|
||||
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||
import IconButton from "../IconButton.vue";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
|
||||
export default {
|
||||
@@ -61,6 +76,7 @@ export default {
|
||||
IconButton,
|
||||
DropDownMenuItem,
|
||||
DropDownMenu,
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
peer: Object,
|
||||
@@ -68,8 +84,72 @@ export default {
|
||||
emits: [
|
||||
"conversation-deleted",
|
||||
"set-custom-display-name",
|
||||
"block-status-changed",
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
isBlocked: false,
|
||||
blockedDestinations: [],
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadBlockedDestinations();
|
||||
},
|
||||
watch: {
|
||||
peer: {
|
||||
handler() {
|
||||
this.checkIfBlocked();
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async loadBlockedDestinations() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/blocked-destinations");
|
||||
this.blockedDestinations = response.data.blocked_destinations || [];
|
||||
this.checkIfBlocked();
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
checkIfBlocked() {
|
||||
if (!this.peer) {
|
||||
this.isBlocked = false;
|
||||
return;
|
||||
}
|
||||
this.isBlocked = this.blockedDestinations.some(
|
||||
b => b.destination_hash === this.peer.destination_hash
|
||||
);
|
||||
},
|
||||
async onBlockDestination() {
|
||||
if (!await DialogUtils.confirm("Are you sure you want to block this user? They will not be able to send you messages or establish links.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.axios.post("/api/v1/blocked-destinations", {
|
||||
destination_hash: this.peer.destination_hash,
|
||||
});
|
||||
await this.loadBlockedDestinations();
|
||||
DialogUtils.alert("User blocked successfully");
|
||||
this.$emit("block-status-changed");
|
||||
} catch(e) {
|
||||
DialogUtils.alert("Failed to block user");
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async onUnblockDestination() {
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/blocked-destinations/${this.peer.destination_hash}`);
|
||||
await this.loadBlockedDestinations();
|
||||
DialogUtils.alert("User unblocked successfully");
|
||||
this.$emit("block-status-changed");
|
||||
} catch(e) {
|
||||
DialogUtils.alert("Failed to unblock user");
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async onDeleteMessageHistory() {
|
||||
|
||||
// ask user to confirm deleting conversation history
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
<template>
|
||||
|
||||
<!-- peer selected -->
|
||||
<div v-if="selectedPeer" class="flex flex-col h-full bg-white overflow-hidden sm:m-2 sm:border sm:rounded-xl sm:shadow dark:bg-zinc-950 dark:border-zinc-800">
|
||||
<div v-if="selectedPeer" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-3 sm:border sm:rounded-2xl sm:shadow-lg border-gray-200/50 dark:border-zinc-800/50 transition-all">
|
||||
|
||||
<!-- header -->
|
||||
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
|
||||
<div class="flex items-center px-4 py-3 border-b border-gray-200/60 dark:border-zinc-800/60 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm">
|
||||
|
||||
<!-- peer icon -->
|
||||
<div class="my-auto mr-2">
|
||||
<div v-if="selectedPeer.lxmf_user_icon" class="p-2 rounded" :style="{ 'color': selectedPeer.lxmf_user_icon.foreground_colour, 'background-color': selectedPeer.lxmf_user_icon.background_colour }">
|
||||
<div class="flex-shrink-0 mr-3">
|
||||
<div v-if="selectedPeer.lxmf_user_icon" class="p-2 rounded shadow-sm" :style="{ 'color': selectedPeer.lxmf_user_icon.foreground_colour, 'background-color': selectedPeer.lxmf_user_icon.background_colour }">
|
||||
<MaterialDesignIcon :icon-name="selectedPeer.lxmf_user_icon.icon_name" class="w-6 h-6"/>
|
||||
</div>
|
||||
<div v-else class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded">
|
||||
<div v-else class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded shadow-sm">
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- peer info -->
|
||||
<div>
|
||||
<div @click="updateCustomDisplayName" class="flex cursor-pointer">
|
||||
<div v-if="selectedPeer.custom_display_name != null" class="my-auto mr-1 dark:text-white" title="Custom Display Name">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div @click="updateCustomDisplayName" class="flex items-center cursor-pointer min-w-0 group">
|
||||
<div v-if="selectedPeer.custom_display_name != null" class="mr-1.5 text-gray-500 dark:text-zinc-400 group-hover:text-gray-700 dark:group-hover:text-zinc-200 transition-colors" title="Custom Display Name">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto font-semibold dark:text-white" :title="selectedPeer.display_name">{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-xs sm:max-w-sm text-base" :title="selectedPeer.custom_display_name ?? selectedPeer.display_name">
|
||||
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm dark:text-zinc-300">
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
|
||||
|
||||
<!-- destination hash -->
|
||||
<div class="inline-block mr-1">
|
||||
@@ -62,18 +64,22 @@
|
||||
</div>
|
||||
|
||||
<!-- dropdown menu -->
|
||||
<div class="ml-auto my-auto mx-2">
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<ConversationDropDownMenu
|
||||
v-if="selectedPeer"
|
||||
:peer="selectedPeer"
|
||||
@conversation-deleted="onConversationDeleted"
|
||||
@set-custom-display-name="updateCustomDisplayName"/>
|
||||
</div>
|
||||
@set-custom-display-name="updateCustomDisplayName"
|
||||
@block-status-changed="loadBlockedDestinations"/>
|
||||
|
||||
<!-- popout button -->
|
||||
<IconButton @click="openConversationPopout" title="Pop out chat" class="text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-200">
|
||||
<MaterialDesignIcon icon-name="open-in-new" class="w-4 h-4"/>
|
||||
</IconButton>
|
||||
|
||||
<!-- close button -->
|
||||
<div class="my-auto mr-2">
|
||||
<IconButton @click="close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<!-- close button -->
|
||||
<IconButton @click="close" class="text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
@@ -82,37 +88,59 @@
|
||||
</div>
|
||||
|
||||
<!-- chat items -->
|
||||
<div @scroll="onMessagesScroll" id="messages" class="h-full overflow-y-scroll">
|
||||
<div @scroll="onMessagesScroll" id="messages" class="h-full overflow-y-scroll bg-gray-50/30 dark:bg-zinc-950/50">
|
||||
|
||||
<div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse p-3">
|
||||
<div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse px-4 py-6">
|
||||
|
||||
<div v-for="chatItem of selectedPeerChatItemsReversed" :key="chatItem.lxmf_message.hash" class="flex flex-col max-w-xl mt-3" :class="{ 'ml-auto pl-4 md:pl-16 items-end': chatItem.is_outbound, 'mr-auto pr-4 md:pr-16 items-start': !chatItem.is_outbound }">
|
||||
<div v-for="chatItem of selectedPeerChatItemsReversed" :key="chatItem.lxmf_message.hash" class="flex flex-col max-w-[75%] sm:max-w-[65%] lg:max-w-[55%] mb-4 group" :class="{ 'ml-auto items-end': chatItem.is_outbound, 'mr-auto items-start': !chatItem.is_outbound }">
|
||||
|
||||
<!-- message content -->
|
||||
<div @click="onChatItemClick(chatItem)" class="border border-gray-300 dark:border-zinc-800 rounded-xl shadow overflow-hidden" :class="[ ['cancelled', 'failed'].includes(chatItem.lxmf_message.state) ? 'bg-red-500 text-white' : chatItem.is_outbound ? 'bg-[#3b82f6] text-white' : 'bg-[#efefef]' ]">
|
||||
<div @click="onChatItemClick(chatItem)" class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md" :class="[
|
||||
['cancelled', 'failed'].includes(chatItem.lxmf_message.state)
|
||||
? 'bg-red-500 text-white shadow-sm'
|
||||
: chatItem.lxmf_message.is_spam
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-900 dark:text-yellow-100 border border-yellow-300 dark:border-yellow-700 shadow-sm'
|
||||
: chatItem.is_outbound
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'bg-white dark:bg-zinc-900 text-gray-900 dark:text-zinc-100 border border-gray-200/60 dark:border-zinc-800/60 shadow-sm'
|
||||
]">
|
||||
|
||||
<div class="w-full space-y-0.5 px-2.5 py-1">
|
||||
<div class="w-full space-y-1 px-4 py-2.5">
|
||||
|
||||
<!-- spam badge -->
|
||||
<div v-if="chatItem.lxmf_message.is_spam" class="flex items-center gap-1.5 text-xs font-medium mb-1" :class="chatItem.is_outbound ? 'text-yellow-200' : 'text-yellow-700 dark:text-yellow-300'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
<span>Marked as Spam</span>
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<div v-if="chatItem.lxmf_message.content" style="white-space:pre-wrap;word-break:break-word;font-family:inherit;">{{ chatItem.lxmf_message.content }}</div>
|
||||
<div v-if="chatItem.lxmf_message.content" class="text-sm leading-relaxed whitespace-pre-wrap break-words" style="font-family:inherit;">{{ chatItem.lxmf_message.content }}</div>
|
||||
|
||||
<!-- image field -->
|
||||
<div v-if="chatItem.lxmf_message.fields?.image">
|
||||
<img @click.stop="openImage(`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`)" :src="`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`" class="w-full rounded-md cursor-pointer"/>
|
||||
<div v-if="chatItem.lxmf_message.fields?.image" class="relative group mt-1 -mx-1">
|
||||
<img
|
||||
@click.stop="openImage(`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`)"
|
||||
:src="`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`"
|
||||
class="w-full rounded-lg cursor-pointer transition-transform group-hover:scale-[1.01]"/>
|
||||
<div class="absolute bottom-2 left-2 bg-black/60 backdrop-blur-sm text-white text-xs px-2.5 py-1 rounded-lg flex items-center gap-1.5">
|
||||
<span>{{ (chatItem.lxmf_message.fields.image.image_type ?? 'image').toUpperCase() }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ formatBase64Bytes(chatItem.lxmf_message.fields.image.image_bytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- audio field -->
|
||||
<div v-if="chatItem.lxmf_message.fields?.audio" class="pb-1">
|
||||
|
||||
<!-- audio is loaded -->
|
||||
<audio v-if="lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" controls class="shadow rounded-full" style="height:54px;">
|
||||
<source :src="lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" type="audio/wav"/>
|
||||
</audio>
|
||||
<audio v-if="lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" controls class="w-full rounded-lg shadow-sm" style="height:54px;" :class="chatItem.is_outbound ? 'audio-controls-light' : 'audio-controls-dark'"></audio>
|
||||
|
||||
<!-- audio is not yet loaded -->
|
||||
<!-- min height to make sure audio player doesn't cause height increase after loading -->
|
||||
<div v-else style="min-height:54px;" class="flex">
|
||||
<button @click="downloadFileFromBase64('audio.bin', chatItem.lxmf_message.fields.audio.audio_bytes)" type="button" class="my-auto flex border border-gray-300 dark:border-zinc-800 hover:bg-gray-100 rounded px-2 py-1 text-sm text-gray-700 font-semibold cursor-pointer space-x-2 bg-[#efefef]">
|
||||
<button @click="downloadFileFromBase64('audio.bin', chatItem.lxmf_message.fields.audio.audio_bytes)" type="button" class="my-auto flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors" :class="chatItem.is_outbound ? 'bg-white/20 text-white border-white/20 hover:bg-white/30' : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'">
|
||||
<span class="my-auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" />
|
||||
@@ -129,17 +157,30 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs mt-1.5" :class="chatItem.is_outbound ? 'text-white/70' : 'text-gray-500 dark:text-zinc-400'">
|
||||
Audio • {{ formatBase64Bytes(chatItem.lxmf_message.fields.audio.audio_bytes) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- file attachment fields -->
|
||||
<div v-if="chatItem.lxmf_message.fields?.file_attachments" class="space-y-1">
|
||||
<a @click.stop target="_blank" :download="file_attachment.file_name" :href="`data:application/octet-stream;base64,${file_attachment.file_bytes}`" v-for="file_attachment of chatItem.lxmf_message.fields?.file_attachments ?? []" class="flex border border-gray-300 dark:border-zinc-800 hover:bg-gray-100 rounded px-2 py-1 text-sm text-gray-700 font-semibold cursor-pointer space-x-2 bg-[#efefef]">
|
||||
<div v-if="chatItem.lxmf_message.fields?.file_attachments" class="space-y-2 mt-1">
|
||||
<a
|
||||
v-for="file_attachment of chatItem.lxmf_message.fields?.file_attachments ?? []"
|
||||
:key="file_attachment.file_name"
|
||||
@click.stop
|
||||
target="_blank"
|
||||
:download="file_attachment.file_name"
|
||||
:href="`data:application/octet-stream;base64,${file_attachment.file_bytes}`"
|
||||
class="flex items-center gap-3 border rounded-lg px-3 py-2 text-sm font-medium cursor-pointer transition-colors" :class="chatItem.is_outbound ? 'bg-white/20 text-white border-white/20 hover:bg-white/30' : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300 border-gray-200/60 dark:border-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-800'">
|
||||
<div class="my-auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto w-full">{{ file_attachment.file_name }}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="truncate">{{ file_attachment.file_name }}</div>
|
||||
<div class="text-xs font-normal mt-0.5" :class="chatItem.is_outbound ? 'text-white/60' : 'text-gray-500 dark:text-zinc-400'">{{ formatBase64Bytes(file_attachment.file_bytes) }}</div>
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
@@ -151,10 +192,10 @@
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div v-if="chatItem.is_actions_expanded" class="border-t p-1 bg-[#efefef] text-white">
|
||||
<div v-if="chatItem.is_actions_expanded" class="border-t px-4 py-2.5" :class="chatItem.is_outbound ? 'border-white/20 bg-white/10' : 'border-gray-200/60 dark:border-zinc-800/60 bg-gray-50/50 dark:bg-zinc-900/50'">
|
||||
|
||||
<!-- delete message -->
|
||||
<button @click.stop="deleteChatItem(chatItem)" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2 py-1 text-xs font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
<button @click.stop="deleteChatItem(chatItem)" type="button" class="inline-flex items-center gap-x-1.5 rounded-lg bg-red-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-600 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -163,12 +204,12 @@
|
||||
</div>
|
||||
|
||||
<!-- message state -->
|
||||
<div v-if="chatItem.is_outbound" class="flex text-right" :class="[ ['cancelled', 'failed'].includes(chatItem.lxmf_message.state) ? 'text-red-500' : 'text-gray-500' ]">
|
||||
<div class="flex ml-auto space-x-1">
|
||||
<div v-if="chatItem.is_outbound" class="flex text-right mt-1.5 px-1" :class="[ ['cancelled', 'failed'].includes(chatItem.lxmf_message.state) ? 'text-red-500 dark:text-red-400' : 'text-gray-400 dark:text-zinc-500' ]">
|
||||
<div class="flex ml-auto items-center space-x-1.5 text-xs">
|
||||
|
||||
<!-- state label -->
|
||||
<div class="my-auto">
|
||||
<span @click="showSentMessageInfo(chatItem.lxmf_message)" class="space-x-1 cursor-pointer">
|
||||
<span @click="toggleSentMessageInfo(chatItem.lxmf_message.hash)" class="space-x-1 cursor-pointer hover:underline">
|
||||
<span>{{ chatItem.lxmf_message.state }}</span>
|
||||
<span v-if="chatItem.lxmf_message.state === 'outbound' && chatItem.lxmf_message.delivery_attempts >= 1">(attempt {{ chatItem.lxmf_message.delivery_attempts + 1 }})</span>
|
||||
<span v-if="chatItem.lxmf_message.state === 'sent' && chatItem.lxmf_message.method === 'opportunistic' && chatItem.lxmf_message.delivery_attempts >= 1">(attempt {{ chatItem.lxmf_message.delivery_attempts }})</span>
|
||||
@@ -211,18 +252,23 @@
|
||||
</div>
|
||||
|
||||
<!-- inbound message info -->
|
||||
<div v-if="!chatItem.is_outbound" class="text-xs text-gray-500 mt-0.5 flex flex-col">
|
||||
<div v-if="!chatItem.is_outbound" class="text-xs text-gray-400 dark:text-zinc-500 mt-1.5 px-1 flex flex-col">
|
||||
|
||||
<!-- received timestamp -->
|
||||
<span @click="showReceivedMessageInfo(chatItem.lxmf_message)" class="cursor-pointer">{{ formatTimeAgo(chatItem.lxmf_message.created_at) }}</span>
|
||||
<span @click="toggleReceivedMessageInfo(chatItem.lxmf_message.hash)" class="cursor-pointer hover:underline">{{ formatTimeAgo(chatItem.lxmf_message.created_at) }}</span>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- expanded message details -->
|
||||
<div v-if="expandedMessageInfo === chatItem.lxmf_message.hash" class="mt-2 px-1 text-xs text-gray-500 dark:text-zinc-400 space-y-0.5">
|
||||
<div v-for="(line, index) in getMessageInfoLines(chatItem.lxmf_message, chatItem.is_outbound)" :key="index">{{ line }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- load previous -->
|
||||
<button v-show="!isLoadingPrevious && hasMorePrevious" id="load-previous" @click="loadPrevious" type="button" class="flex space-x-2 mx-auto bg-gray-200 px-3 py-1 hover:bg-gray-300 rounded-full shadow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<button v-show="!isLoadingPrevious && hasMorePrevious" id="load-previous" @click="loadPrevious" type="button" class="flex items-center gap-2 mx-auto mt-4 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 px-4 py-2 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-full shadow-sm text-sm font-medium text-gray-700 dark:text-zinc-300 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m15 11.25-3-3m0 0-3 3m3-3v7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
<span>Load Previous</span>
|
||||
@@ -233,83 +279,60 @@
|
||||
</div>
|
||||
|
||||
<!-- send message -->
|
||||
<div class="w-full border-gray-300 dark:border-zinc-800 border-t p-2">
|
||||
<div class="mx-auto">
|
||||
<div class="w-full border-t border-gray-200/60 dark:border-zinc-800/60 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm px-3 sm:px-4 py-2.5">
|
||||
<div class="w-full">
|
||||
|
||||
<!-- blocked user notification -->
|
||||
<div v-if="isSelectedPeerBlocked" class="mb-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
<span class="text-sm text-yellow-800 dark:text-yellow-200">You have blocked this user. They cannot send you messages or establish links.</span>
|
||||
</div>
|
||||
|
||||
<!-- message composer -->
|
||||
<div>
|
||||
|
||||
<!-- image attachment -->
|
||||
<div v-if="newMessageImage" class="mb-2">
|
||||
<div @click.stop="openImage(newMessageImageUrl)" class="cursor-pointer w-32 h-32 rounded shadow border relative overflow-hidden">
|
||||
|
||||
<!-- image preview -->
|
||||
<img v-if="newMessageImageUrl" :src="newMessageImageUrl" class="w-full h-full object-cover"/>
|
||||
|
||||
<!-- remove button (top right) -->
|
||||
<div class="absolute top-0 right-0 p-1">
|
||||
<div @click.stop="removeImageAttachment" class="cursor-pointer">
|
||||
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<!-- image attachment -->
|
||||
<div v-if="newMessageImage" class="attachment-card">
|
||||
<div class="attachment-card__preview" @click.stop="openImage(newMessageImageUrl)">
|
||||
<img v-if="newMessageImageUrl" :src="newMessageImageUrl" class="w-full h-full object-cover rounded-lg"/>
|
||||
</div>
|
||||
|
||||
<!-- image size (bottom left) -->
|
||||
<div class="absolute bottom-0 left-0 p-1">
|
||||
<div class="bg-gray-100 rounded border text-sm px-1">{{ formatBytes(newMessageImage.size) }}</div>
|
||||
<div class="attachment-card__body">
|
||||
<div class="attachment-card__title">Image Attachment</div>
|
||||
<div class="attachment-card__meta">{{ formatBytes(newMessageImage.size) }}</div>
|
||||
</div>
|
||||
|
||||
<button @click.stop="removeImageAttachment" type="button" class="attachment-card__remove">
|
||||
<MaterialDesignIcon icon-name="close" class="w-4 h-4"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- audio attachment -->
|
||||
<div v-if="newMessageAudio" class="mb-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex border border-gray-300 dark:border-zinc-800 rounded text-gray-700 divide-x divide-gray-300 overflow-hidden">
|
||||
|
||||
<div class="flex p-1">
|
||||
|
||||
<!-- audio preview -->
|
||||
<div>
|
||||
<audio controls class="h-10">
|
||||
<source :src="newMessageAudio.audio_preview_url" type="audio/wav"/>
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<!-- encoded file size -->
|
||||
<div class="my-auto px-1 text-sm text-gray-500">
|
||||
{{ formatBytes(newMessageAudio.audio_blob.size) }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- remove audio attachment -->
|
||||
<div @click="removeAudioAttachment" class="flex my-auto text-sm text-gray-500 h-full px-1 hover:bg-gray-200 cursor-pointer">
|
||||
<svg class="w-5 h-5 my-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- audio attachment -->
|
||||
<div v-if="newMessageAudio" class="attachment-card">
|
||||
<div class="attachment-card__body w-full">
|
||||
<div class="attachment-card__title">Voice Note</div>
|
||||
<div class="attachment-card__meta">{{ formatBytes(newMessageAudio.audio_blob.size) }}</div>
|
||||
<audio controls class="w-full mt-2 rounded-lg shadow-sm audio-controls-dark" style="height:54px;">
|
||||
<source :src="newMessageAudio.audio_preview_url" type="audio/wav"/>
|
||||
</audio>
|
||||
</div>
|
||||
<button @click="removeAudioAttachment" type="button" class="attachment-card__remove">
|
||||
<MaterialDesignIcon icon-name="delete" class="w-4 h-4"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- file attachments -->
|
||||
<div v-if="newMessageFiles.length > 0" class="mb-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div v-for="file in newMessageFiles" class="flex border border-gray-300 dark:border-zinc-800 rounded text-gray-700 divide-x divide-gray-300 overflow-hidden dark:border-zinc-800">
|
||||
<div class="my-auto px-1">
|
||||
<span class="mr-1">{{ file.name }}</span>
|
||||
<span class="my-auto text-sm text-gray-500">{{ formatBytes(file.size) }}</span>
|
||||
</div>
|
||||
<div @click="removeFileAttachment(file)" class="flex my-auto text-sm text-gray-500 h-full px-1 hover:bg-gray-200 cursor-pointer">
|
||||
<svg class="w-5 h-5 my-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<!-- file attachments -->
|
||||
<div v-if="newMessageFiles.length > 0" class="flex flex-wrap gap-2">
|
||||
<div v-for="file in newMessageFiles" :key="file.name + file.size" class="attachment-chip">
|
||||
<div class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4 text-gray-500 dark:text-gray-300"/>
|
||||
<div class="text-sm text-gray-800 dark:text-gray-200 truncate max-w-[160px]">{{ file.name }}</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatBytes(file.size) }}</span>
|
||||
</div>
|
||||
<button @click="removeFileAttachment(file)" type="button" class="attachment-chip__remove">
|
||||
<MaterialDesignIcon icon-name="close" class="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,38 +345,23 @@
|
||||
v-model="newMessageText"
|
||||
@keydown.enter.exact.native.prevent="onEnterPressed"
|
||||
@keydown.enter.shift.exact.native.prevent="onShiftEnterPressed"
|
||||
class="bg-gray-50 border border-gray-300 dark:border-zinc-800 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-800 dark:text-zinc-100 dark:border-zinc-900"
|
||||
rows="3"
|
||||
placeholder="Send a message..."></textarea>
|
||||
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 block w-full px-3 sm:px-4 py-2 resize-none shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
|
||||
rows="2"
|
||||
placeholder="Type a message..."></textarea>
|
||||
|
||||
<!-- action button -->
|
||||
<div class="flex mt-2">
|
||||
|
||||
<!-- add files -->
|
||||
<button @click="addFilesToMessage" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM12.75 12a.75.75 0 0 0-1.5 0v2.25H9a.75.75 0 0 0 0 1.5h2.25V18a.75.75 0 0 0 1.5 0v-2.25H15a.75.75 0 0 0 0-1.5h-2.25V12Z" clip-rule="evenodd" />
|
||||
<path d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z" />
|
||||
</svg>
|
||||
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Files</span>
|
||||
<div class="flex flex-wrap gap-2 items-center mt-2">
|
||||
<button @click="addFilesToMessage" type="button" class="attachment-action-button">
|
||||
<MaterialDesignIcon icon-name="paperclip-plus" class="w-4 h-4"/>
|
||||
<span>Add Files</span>
|
||||
</button>
|
||||
|
||||
<!-- add image -->
|
||||
<div>
|
||||
<AddImageButton @add-image="onImageSelected"/>
|
||||
</div>
|
||||
|
||||
<!-- add audio -->
|
||||
<div>
|
||||
<AddAudioButton
|
||||
:is-recording-audio-attachment="isRecordingAudioAttachment"
|
||||
@start-recording="startRecordingAudioAttachment($event)"
|
||||
@stop-recording="stopRecordingAudioAttachment">
|
||||
<span>Recording: {{ audioAttachmentRecordingDuration }}</span>
|
||||
</AddAudioButton>
|
||||
</div>
|
||||
|
||||
<!-- send message -->
|
||||
<AddImageButton @add-image="onImageSelected"/>
|
||||
<AddAudioButton
|
||||
:is-recording-audio-attachment="isRecordingAudioAttachment"
|
||||
@start-recording="startRecordingAudioAttachment($event)"
|
||||
@stop-recording="stopRecordingAudioAttachment">
|
||||
<span>Recording: {{ audioAttachmentRecordingDuration }}</span>
|
||||
</AddAudioButton>
|
||||
<div class="ml-auto my-auto">
|
||||
<SendMessageButton
|
||||
@send="sendMessage"
|
||||
@@ -362,7 +370,6 @@
|
||||
:can-send-message="canSendMessage"
|
||||
:delivery-method="newMessageDeliveryMethod"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -376,23 +383,53 @@
|
||||
</div>
|
||||
|
||||
<!-- no peer selected -->
|
||||
<div v-else class="flex flex-col mx-auto my-auto text-center leading-5">
|
||||
<div class="mx-auto mb-1 ">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 dark:text-white">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold dark:text-white">No Active Chat</div>
|
||||
<div class='dark:text-zinc-300'>Select a Peer to start chatting!</div>
|
||||
<div class="mx-auto mt-2">
|
||||
<button @click.stop="openLXMFAddress" type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500
|
||||
dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||
Enter an LXMF Address
|
||||
</button>
|
||||
<div v-else class="flex flex-col h-full items-center justify-center">
|
||||
<div class="w-full max-w-md px-4">
|
||||
<div class="mb-6 text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-blue-100 to-blue-200 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-blue-600 dark:text-blue-400">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-zinc-100 mb-1">No Active Chat</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-zinc-400">Select a peer from the sidebar or enter an address below</p>
|
||||
</div>
|
||||
|
||||
<!-- compose message input -->
|
||||
<div class="w-full">
|
||||
<input
|
||||
ref="compose-input"
|
||||
id="compose-input"
|
||||
:readonly="isSendingMessage"
|
||||
v-model="composeAddress"
|
||||
@keydown.enter.exact.prevent="onComposeEnterPressed"
|
||||
type="text"
|
||||
class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
|
||||
placeholder="Enter LXMF address..."/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- image modal -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0">
|
||||
<div v-if="imageModalUrl" @click="closeImageModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 dark:bg-black/90 backdrop-blur-sm p-4">
|
||||
<div @click.stop class="relative max-w-7xl max-h-full">
|
||||
<button @click="closeImageModal" type="button" class="absolute -top-12 right-0 inline-flex items-center justify-center w-10 h-10 rounded-xl bg-white/10 dark:bg-zinc-900/10 hover:bg-white/20 dark:hover:bg-zinc-900/20 text-white transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<img :src="imageModalUrl" class="max-w-full max-h-[90vh] rounded-xl shadow-2xl" alt="Image preview"/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -446,6 +483,7 @@ export default {
|
||||
newMessageFiles: [],
|
||||
isSendingMessage: false,
|
||||
autoScrollOnNewMessage: true,
|
||||
composeAddress: "",
|
||||
|
||||
isRecordingAudioAttachment: false,
|
||||
audioAttachmentMicrophoneRecorder: null,
|
||||
@@ -454,6 +492,10 @@ export default {
|
||||
audioAttachmentRecordingDuration: null,
|
||||
audioAttachmentRecordingTimer: null,
|
||||
lxmfMessageAudioAttachmentCache: {},
|
||||
expandedMessageInfo: null,
|
||||
imageModalUrl: null,
|
||||
isSelectedPeerBlocked: false,
|
||||
blockedDestinations: [],
|
||||
lxmfAudioModeToCodec2ModeMap: {
|
||||
// https://github.com/markqvist/LXMF/blob/master/LXMF/LXMF.py#L21
|
||||
0x01: "450PWB", // AM_CODEC2_450PWB
|
||||
@@ -472,14 +514,47 @@ export default {
|
||||
beforeUnmount() {
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
|
||||
},
|
||||
watch: {
|
||||
selectedPeer: {
|
||||
handler() {
|
||||
this.checkIfSelectedPeerBlocked();
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
||||
// listen for websocket messages
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
|
||||
// listen for compose new message event
|
||||
GlobalEmitter.on("compose-new-message", this.onComposeNewMessageEvent);
|
||||
|
||||
// load blocked destinations
|
||||
this.loadBlockedDestinations();
|
||||
|
||||
},
|
||||
methods: {
|
||||
async loadBlockedDestinations() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/blocked-destinations");
|
||||
this.blockedDestinations = response.data.blocked_destinations || [];
|
||||
this.checkIfSelectedPeerBlocked();
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
checkIfSelectedPeerBlocked() {
|
||||
if (!this.selectedPeer) {
|
||||
this.isSelectedPeerBlocked = false;
|
||||
return;
|
||||
}
|
||||
this.isSelectedPeerBlocked = this.blockedDestinations.some(
|
||||
b => b.destination_hash === this.selectedPeer.destination_hash
|
||||
);
|
||||
},
|
||||
close() {
|
||||
this.$emit("close");
|
||||
},
|
||||
@@ -614,6 +689,37 @@ export default {
|
||||
openLXMFAddress() {
|
||||
GlobalEmitter.emit("compose-new-message");
|
||||
},
|
||||
onComposeNewMessageEvent(destinationHash) {
|
||||
if(!this.selectedPeer && !destinationHash){
|
||||
this.$nextTick(() => {
|
||||
const composeInput = document.getElementById("compose-input");
|
||||
if(composeInput){
|
||||
composeInput.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
async onComposeSubmit() {
|
||||
if(!this.composeAddress || this.composeAddress.trim() === ""){
|
||||
return;
|
||||
}
|
||||
let destinationHash = this.composeAddress.trim();
|
||||
this.composeAddress = "";
|
||||
await this.handleComposeAddress(destinationHash);
|
||||
},
|
||||
onComposeEnterPressed() {
|
||||
this.onComposeSubmit();
|
||||
},
|
||||
async handleComposeAddress(destinationHash) {
|
||||
if(destinationHash.startsWith("lxmf@")){
|
||||
destinationHash = destinationHash.replace("lxmf@", "");
|
||||
}
|
||||
if(destinationHash.length !== 32){
|
||||
DialogUtils.alert("Invalid Address");
|
||||
return;
|
||||
}
|
||||
GlobalEmitter.emit("compose-new-message", destinationHash);
|
||||
},
|
||||
onLxmfMessageReceived(lxmfMessage) {
|
||||
|
||||
// add inbound message to ui
|
||||
@@ -862,16 +968,10 @@ export default {
|
||||
}
|
||||
},
|
||||
openImage: async function(url) {
|
||||
|
||||
// convert data uri to blob
|
||||
const blob = await (await fetch(url)).blob();
|
||||
|
||||
// create blob url
|
||||
const fileUrl = window.URL.createObjectURL(blob);
|
||||
|
||||
// open new tab
|
||||
window.open(fileUrl);
|
||||
|
||||
this.imageModalUrl = url;
|
||||
},
|
||||
closeImageModal() {
|
||||
this.imageModalUrl = null;
|
||||
},
|
||||
downloadFileFromBase64: async function(fileName, fileBytesBase64) {
|
||||
|
||||
@@ -1205,6 +1305,23 @@ export default {
|
||||
formatBytes: function(bytes) {
|
||||
return Utils.formatBytes(bytes);
|
||||
},
|
||||
base64ByteLength(base64String) {
|
||||
if(!base64String){
|
||||
return 0;
|
||||
}
|
||||
const padding = (base64String.match(/=+$/) || [""])[0].length;
|
||||
return Math.floor(base64String.length * 3 / 4) - padding;
|
||||
},
|
||||
formatBase64Bytes(base64String) {
|
||||
return this.formatBytes(this.base64ByteLength(base64String));
|
||||
},
|
||||
openConversationPopout() {
|
||||
if (!this.selectedPeer) return;
|
||||
const destinationHash = this.selectedPeer.destination_hash || "";
|
||||
const encodedHash = encodeURIComponent(destinationHash);
|
||||
const url = `${window.location.origin}${window.location.pathname}#/popout/messages/${encodedHash}`;
|
||||
window.open(url, "_blank", "width=960,height=720,noopener");
|
||||
},
|
||||
onFileInputChange: function(event) {
|
||||
for(const file of event.target.files){
|
||||
this.newMessageFiles.push(file);
|
||||
@@ -1464,89 +1581,64 @@ export default {
|
||||
this.$emit("reload-conversations");
|
||||
|
||||
},
|
||||
showSentMessageInfo: function(lxmfMessage) {
|
||||
|
||||
// basic info
|
||||
const info = [
|
||||
`Created: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`,
|
||||
`Method: ${lxmfMessage.method ?? "unknown"}`,
|
||||
];
|
||||
|
||||
// add audio attachment size
|
||||
if(lxmfMessage.fields?.audio?.audio_bytes){
|
||||
const audioBytesLength = atob(lxmfMessage.fields?.audio?.audio_bytes).length;
|
||||
info.push(`Audio Attachment: ${this.formatBytes(audioBytesLength)}`);
|
||||
toggleSentMessageInfo: function(messageHash) {
|
||||
if(this.expandedMessageInfo === messageHash){
|
||||
this.expandedMessageInfo = null;
|
||||
} else {
|
||||
this.expandedMessageInfo = messageHash;
|
||||
}
|
||||
|
||||
// add image attachment size
|
||||
if(lxmfMessage.fields?.image?.image_bytes){
|
||||
const imageBytesLength = atob(lxmfMessage.fields?.image?.image_bytes).length;
|
||||
info.push(`Image Attachment: ${this.formatBytes(imageBytesLength)}`);
|
||||
}
|
||||
|
||||
// add file attachments size
|
||||
if(lxmfMessage.fields?.file_attachments){
|
||||
var filesLength = 0;
|
||||
for(const fileAttachment of lxmfMessage.fields?.file_attachments){
|
||||
const fileBytesLength = atob(fileAttachment.file_bytes).length;
|
||||
filesLength += fileBytesLength;
|
||||
}
|
||||
info.push(`File Attachments: ${this.formatBytes(filesLength)}`);
|
||||
}
|
||||
|
||||
// show message info
|
||||
DialogUtils.alert(info.join("\n"));
|
||||
|
||||
},
|
||||
showReceivedMessageInfo: function(lxmfMessage) {
|
||||
toggleReceivedMessageInfo: function(messageHash) {
|
||||
if(this.expandedMessageInfo === messageHash){
|
||||
this.expandedMessageInfo = null;
|
||||
} else {
|
||||
this.expandedMessageInfo = messageHash;
|
||||
}
|
||||
},
|
||||
getMessageInfoLines: function(lxmfMessage, isOutbound) {
|
||||
const lines = [];
|
||||
|
||||
// basic info
|
||||
const info = [
|
||||
`Sent: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`,
|
||||
`Received: ${Utils.convertDateTimeToLocalDateTimeString(new Date(lxmfMessage.created_at))}`,
|
||||
`Method: ${lxmfMessage.method ?? "unknown"}`,
|
||||
];
|
||||
if(isOutbound){
|
||||
lines.push(`Created: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`);
|
||||
} else {
|
||||
lines.push(`Sent: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`);
|
||||
lines.push(`Received: ${Utils.convertDateTimeToLocalDateTimeString(new Date(lxmfMessage.created_at))}`);
|
||||
}
|
||||
|
||||
lines.push(`Method: ${lxmfMessage.method ?? "unknown"}`);
|
||||
|
||||
// add audio attachment size
|
||||
if(lxmfMessage.fields?.audio?.audio_bytes){
|
||||
const audioBytesLength = atob(lxmfMessage.fields?.audio?.audio_bytes).length;
|
||||
info.push(`Audio Attachment: ${this.formatBytes(audioBytesLength)}`);
|
||||
lines.push(`Audio Attachment: ${this.formatBytes(audioBytesLength)}`);
|
||||
}
|
||||
|
||||
// add image attachment size
|
||||
if(lxmfMessage.fields?.image?.image_bytes){
|
||||
const imageBytesLength = atob(lxmfMessage.fields?.image?.image_bytes).length;
|
||||
info.push(`Image Attachment: ${this.formatBytes(imageBytesLength)}`);
|
||||
lines.push(`Image Attachment: ${this.formatBytes(imageBytesLength)}`);
|
||||
}
|
||||
|
||||
// add file attachments size
|
||||
if(lxmfMessage.fields?.file_attachments){
|
||||
var filesLength = 0;
|
||||
for(const fileAttachment of lxmfMessage.fields?.file_attachments){
|
||||
const fileBytesLength = atob(fileAttachment.file_bytes).length;
|
||||
filesLength += fileBytesLength;
|
||||
}
|
||||
info.push(`File Attachments: ${this.formatBytes(filesLength)}`);
|
||||
lines.push(`File Attachments: ${this.formatBytes(filesLength)}`);
|
||||
}
|
||||
|
||||
// add signal quality if available
|
||||
if(lxmfMessage.quality != null){
|
||||
info.push(`Signal Quality: ${lxmfMessage.quality}%`);
|
||||
if(!isOutbound){
|
||||
if(lxmfMessage.quality != null){
|
||||
lines.push(`Signal Quality: ${lxmfMessage.quality}%`);
|
||||
}
|
||||
if(lxmfMessage.rssi != null){
|
||||
lines.push(`RSSI: ${lxmfMessage.rssi}dBm`);
|
||||
}
|
||||
if(lxmfMessage.snr != null){
|
||||
lines.push(`SNR: ${lxmfMessage.snr}dB`);
|
||||
}
|
||||
}
|
||||
|
||||
// add rssi if available
|
||||
if(lxmfMessage.rssi != null){
|
||||
info.push(`RSSI: ${lxmfMessage.rssi}dBm`);
|
||||
}
|
||||
|
||||
// add snr if available
|
||||
if(lxmfMessage.snr != null){
|
||||
info.push(`SNR: ${lxmfMessage.snr}dB`);
|
||||
}
|
||||
|
||||
// show message info
|
||||
DialogUtils.alert(info.join("\n"));
|
||||
|
||||
return lines;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
@@ -1618,3 +1710,57 @@ export default {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attachment-card {
|
||||
@apply relative flex gap-3 border border-gray-200 dark:border-zinc-800 rounded-2xl p-3 shadow-sm;
|
||||
background-color: white;
|
||||
}
|
||||
.dark .attachment-card {
|
||||
background-color: rgb(24 24 27);
|
||||
}
|
||||
.attachment-card__preview {
|
||||
@apply w-24 h-24 overflow-hidden rounded-xl bg-gray-100 dark:bg-zinc-800 cursor-pointer;
|
||||
}
|
||||
.attachment-card__body {
|
||||
@apply flex-1;
|
||||
}
|
||||
.attachment-card__title {
|
||||
@apply text-sm font-semibold text-gray-800 dark:text-gray-100;
|
||||
}
|
||||
.attachment-card__meta {
|
||||
@apply text-xs text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
.attachment-card__remove {
|
||||
@apply absolute top-2 right-2 inline-flex items-center justify-center w-6 h-6 rounded-full bg-gray-200 dark:bg-zinc-800 text-gray-600 dark:text-gray-200 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/40;
|
||||
}
|
||||
.attachment-chip {
|
||||
@apply flex items-center justify-between gap-2 border border-gray-200 dark:border-zinc-800 rounded-full px-3 py-1 text-xs shadow-sm;
|
||||
background-color: white;
|
||||
}
|
||||
.dark .attachment-chip {
|
||||
background-color: rgb(24 24 27);
|
||||
}
|
||||
.attachment-chip__remove {
|
||||
@apply inline-flex items-center justify-center text-gray-500 dark:text-gray-300 hover:text-red-500;
|
||||
}
|
||||
.attachment-action-button {
|
||||
@apply inline-flex items-center gap-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition;
|
||||
}
|
||||
|
||||
.audio-controls-light {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
|
||||
.dark .audio-controls-light {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.audio-controls-dark {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.dark .audio-controls-dark {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<template>
|
||||
|
||||
<MessagesSidebar
|
||||
v-if="!isPopoutMode"
|
||||
:conversations="conversations"
|
||||
:peers="peers"
|
||||
:selected-destination-hash="selectedPeer?.destination_hash"
|
||||
:conversation-search-term="conversationSearchTerm"
|
||||
:filter-unread-only="filterUnreadOnly"
|
||||
:filter-failed-only="filterFailedOnly"
|
||||
:filter-has-attachments-only="filterHasAttachmentsOnly"
|
||||
:is-loading="isLoadingConversations"
|
||||
@conversation-click="onConversationClick"
|
||||
@peer-click="onPeerClick"/>
|
||||
@peer-click="onPeerClick"
|
||||
@conversation-search-changed="onConversationSearchChanged"
|
||||
@conversation-filter-changed="onConversationFilterChanged"/>
|
||||
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-white via-slate-50 to-slate-100 dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900/80">
|
||||
|
||||
<!-- messages tab -->
|
||||
<ConversationViewer
|
||||
@@ -45,6 +53,7 @@ export default {
|
||||
return {
|
||||
|
||||
reloadInterval: null,
|
||||
conversationRefreshTimeout: null,
|
||||
|
||||
config: null,
|
||||
peers: {},
|
||||
@@ -53,11 +62,18 @@ export default {
|
||||
conversations: [],
|
||||
lxmfDeliveryAnnounces: [],
|
||||
|
||||
conversationSearchTerm: "",
|
||||
filterUnreadOnly: false,
|
||||
filterFailedOnly: false,
|
||||
filterHasAttachmentsOnly: false,
|
||||
isLoadingConversations: false,
|
||||
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
|
||||
clearInterval(this.reloadInterval);
|
||||
clearTimeout(this.conversationRefreshTimeout);
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
@@ -87,37 +103,36 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async onComposeNewMessage(destinationHash) {
|
||||
|
||||
// ask for destination address if not provided
|
||||
if(destinationHash == null){
|
||||
destinationHash = await DialogUtils.prompt("Enter LXMF Address");
|
||||
if(!destinationHash){
|
||||
if(this.selectedPeer){
|
||||
return;
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
const composeInput = document.getElementById("compose-input");
|
||||
if(composeInput){
|
||||
composeInput.focus();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// if user provided an address with an "lxmf@" prefix, lets remove that to get the raw destination hash
|
||||
if(destinationHash.startsWith("lxmf@")){
|
||||
destinationHash = destinationHash.replace("lxmf@", "");
|
||||
}
|
||||
|
||||
// fetch updated announce as we might be composing new message before we loaded the announces list
|
||||
await this.getLxmfDeliveryAnnounce(destinationHash);
|
||||
|
||||
// attempt to find existing peer so we can show their name
|
||||
const existingPeer = this.peers[destinationHash];
|
||||
if(existingPeer){
|
||||
this.onPeerClick(existingPeer);
|
||||
return;
|
||||
}
|
||||
|
||||
// simple attempt to prevent garbage input
|
||||
if(destinationHash.length !== 32){
|
||||
DialogUtils.alert("Invalid Address");
|
||||
return;
|
||||
}
|
||||
|
||||
// we didn't find an existing peer, so just use an unknown name
|
||||
this.onPeerClick({
|
||||
display_name: "Unknown Peer",
|
||||
destination_hash: destinationHash,
|
||||
@@ -200,13 +215,34 @@ export default {
|
||||
},
|
||||
async getConversations() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
|
||||
this.isLoadingConversations = true;
|
||||
const response = await window.axios.get(`/api/v1/lxmf/conversations`, {
|
||||
params: this.buildConversationQueryParams(),
|
||||
});
|
||||
this.conversations = response.data.conversations;
|
||||
} catch(e) {
|
||||
// do nothing if failed to load conversations
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.isLoadingConversations = false;
|
||||
}
|
||||
},
|
||||
buildConversationQueryParams() {
|
||||
const params = {};
|
||||
if(this.conversationSearchTerm && this.conversationSearchTerm.trim() !== ""){
|
||||
params.search = this.conversationSearchTerm.trim();
|
||||
}
|
||||
if(this.filterUnreadOnly){
|
||||
params.filter_unread = true;
|
||||
}
|
||||
if(this.filterFailedOnly){
|
||||
params.filter_failed = true;
|
||||
}
|
||||
if(this.filterHasAttachmentsOnly){
|
||||
params.filter_has_attachments = true;
|
||||
}
|
||||
return params;
|
||||
},
|
||||
updatePeerFromAnnounce: function(announce) {
|
||||
this.peers[announce.destination_hash] = announce;
|
||||
},
|
||||
@@ -216,12 +252,17 @@ export default {
|
||||
this.selectedPeer = peer;
|
||||
|
||||
// update current route
|
||||
this.$router.replace({
|
||||
name: "messages",
|
||||
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
|
||||
const routeOptions = {
|
||||
name: routeName,
|
||||
params: {
|
||||
destinationHash: peer.destination_hash,
|
||||
},
|
||||
});
|
||||
};
|
||||
if(!this.isPopoutMode && this.$route?.query){
|
||||
routeOptions.query = { ...this.$route.query };
|
||||
}
|
||||
this.$router.replace(routeOptions);
|
||||
|
||||
},
|
||||
onConversationClick: function(conversation) {
|
||||
@@ -238,11 +279,57 @@ export default {
|
||||
// clear selected peer
|
||||
this.selectedPeer = null;
|
||||
|
||||
// update current route
|
||||
this.$router.replace({
|
||||
name: "messages",
|
||||
});
|
||||
if(this.isPopoutMode){
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// update current route
|
||||
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
|
||||
const routeOptions = { name: routeName };
|
||||
if(!this.isPopoutMode && this.$route?.query){
|
||||
routeOptions.query = { ...this.$route.query };
|
||||
}
|
||||
this.$router.replace(routeOptions);
|
||||
|
||||
},
|
||||
requestConversationsRefresh() {
|
||||
if(this.conversationRefreshTimeout){
|
||||
clearTimeout(this.conversationRefreshTimeout);
|
||||
}
|
||||
this.conversationRefreshTimeout = setTimeout(() => {
|
||||
this.getConversations();
|
||||
}, 250);
|
||||
},
|
||||
onConversationSearchChanged(term) {
|
||||
this.conversationSearchTerm = term;
|
||||
this.requestConversationsRefresh();
|
||||
},
|
||||
onConversationFilterChanged(filterKey) {
|
||||
if(filterKey === 'unread'){
|
||||
this.filterUnreadOnly = !this.filterUnreadOnly;
|
||||
} else if(filterKey === 'failed'){
|
||||
this.filterFailedOnly = !this.filterFailedOnly;
|
||||
} else if(filterKey === 'attachments'){
|
||||
this.filterHasAttachmentsOnly = !this.filterHasAttachmentsOnly;
|
||||
}
|
||||
this.requestConversationsRefresh();
|
||||
},
|
||||
getHashPopoutValue() {
|
||||
const hash = window.location.hash || "";
|
||||
const match = hash.match(/popout=([^&]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
popoutRouteType() {
|
||||
if(this.$route?.meta?.popoutType){
|
||||
return this.$route.meta.popoutType;
|
||||
}
|
||||
return this.$route?.query?.popout ?? this.getHashPopoutValue();
|
||||
},
|
||||
isPopoutMode() {
|
||||
return this.popoutRouteType === "conversation";
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
||||
@@ -2,25 +2,40 @@
|
||||
<div class="flex flex-col w-80 min-w-80">
|
||||
|
||||
<!-- tabs -->
|
||||
<div class="bg-white dark:bg-zinc-950 border-b border-r border-gray-200 dark:border-zinc-700">
|
||||
<div class="bg-transparent border-b border-r border-gray-200/70 dark:border-zinc-700/80 backdrop-blur">
|
||||
<div class="-mb-px flex">
|
||||
<div @click="tab = 'conversations'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'conversations' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Conversations</div>
|
||||
<div @click="tab = 'announces'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'announces' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Announces</div>
|
||||
<div @click="tab = 'conversations'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-semibold tracking-wide uppercase cursor-pointer transition" :class="[ tab === 'conversations' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-200']">Conversations</div>
|
||||
<div @click="tab = 'announces'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-semibold tracking-wide uppercase cursor-pointer transition" :class="[ tab === 'announces' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-200']">Announces</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- conversations -->
|
||||
<div v-if="tab === 'conversations'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden">
|
||||
<div v-if="tab === 'conversations'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0">
|
||||
|
||||
<!-- search -->
|
||||
<div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
|
||||
<input v-model="conversationsSearchTerm" type="text" :placeholder="`Search ${conversations.length} Conversations...`" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
<!-- search + filters -->
|
||||
<div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-2">
|
||||
<input
|
||||
:value="conversationSearchTerm"
|
||||
@input="onConversationSearchInput"
|
||||
type="text"
|
||||
:placeholder="`Search ${conversations.length} conversations...`"
|
||||
class="input-field">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button type="button" @click="toggleFilter('unread')" :class="filterChipClasses(filterUnreadOnly)">Unread</button>
|
||||
<button type="button" @click="toggleFilter('failed')" :class="filterChipClasses(filterFailedOnly)">Failed</button>
|
||||
<button type="button" @click="toggleFilter('attachments')" :class="filterChipClasses(filterHasAttachmentsOnly)">Attachments</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- peers -->
|
||||
<!-- conversations -->
|
||||
<div class="flex h-full overflow-y-auto">
|
||||
<div v-if="searchedConversations.length > 0" class="w-full">
|
||||
<div @click="onConversationClick(conversation)" v-for="conversation of searchedConversations" class="flex cursor-pointer p-2 border-l-2" :class="[ conversation.destination_hash === selectedDestinationHash ? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400' : 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600' ]">
|
||||
<div v-if="displayedConversations.length > 0" class="w-full">
|
||||
<div
|
||||
v-for="conversation of displayedConversations"
|
||||
:key="conversation.destination_hash"
|
||||
@click="onConversationClick(conversation)"
|
||||
class="flex cursor-pointer p-2 border-l-2"
|
||||
:class="[ conversation.destination_hash === selectedDestinationHash ? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400' : 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600' ]">
|
||||
<div class="my-auto mr-2">
|
||||
<div v-if="conversation.lxmf_user_icon" class="p-2 rounded" :style="{ 'color': conversation.lxmf_user_icon.foreground_colour, 'background-color': conversation.lxmf_user_icon.background_colour }">
|
||||
<MaterialDesignIcon :icon-name="conversation.lxmf_user_icon.icon_name" class="w-6 h-6"/>
|
||||
@@ -29,22 +44,45 @@
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mr-auto">
|
||||
<div class="text-gray-900 dark:text-gray-100" :class="{ 'font-semibold': conversation.is_unread || conversation.failed_messages_count > 0 }">{{ conversation.custom_display_name ?? conversation.display_name }}</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatTimeAgo(conversation.updated_at) }}</div>
|
||||
<div class="mr-auto w-full pr-2 min-w-0">
|
||||
<div class="flex justify-between gap-2 min-w-0">
|
||||
<div class="text-gray-900 dark:text-gray-100 truncate min-w-0" :title="conversation.custom_display_name ?? conversation.display_name" :class="{ 'font-semibold': conversation.is_unread || conversation.failed_messages_count > 0 }">
|
||||
{{ conversation.custom_display_name ?? conversation.display_name }}
|
||||
</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap flex-shrink-0">
|
||||
{{ formatTimeAgo(conversation.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-xs mt-0.5 truncate">
|
||||
{{ conversation.latest_message_preview ?? conversation.latest_message_title ?? 'No messages yet' }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="conversation.is_unread" class="my-auto ml-2 mr-2">
|
||||
<div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
|
||||
</div>
|
||||
<div v-else-if="conversation.failed_messages_count" class="my-auto ml-2 mr-2">
|
||||
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
|
||||
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4"/>
|
||||
</div>
|
||||
<div v-if="conversation.is_unread" class="my-auto ml-1">
|
||||
<div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
|
||||
</div>
|
||||
<div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
|
||||
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mx-auto my-auto text-center leading-5">
|
||||
|
||||
<div v-if="isLoading" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div class="mx-auto mb-1 animate-spin text-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold">Loading conversations…</div>
|
||||
</div>
|
||||
|
||||
<!-- no conversations at all -->
|
||||
<div v-if="conversations.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div v-else-if="conversations.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div class="mx-auto mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z" />
|
||||
@@ -55,25 +93,25 @@
|
||||
</div>
|
||||
|
||||
<!-- is searching, but no results -->
|
||||
<div v-if="conversationsSearchTerm !== '' && conversations.length > 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div v-else-if="conversationSearchTerm !== ''" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div class="mx-auto mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold">No Search Results</div>
|
||||
<div>Your search didn't match any Conversations!</div>
|
||||
<div>Your search didn't match any conversations.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- discover -->
|
||||
<div v-if="tab === 'announces'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden">
|
||||
<div v-if="tab === 'announces'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0">
|
||||
|
||||
<!-- search -->
|
||||
<div v-if="peersCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
|
||||
<input v-model="peersSearchTerm" type="text" :placeholder="`Search ${peersCount} recent announces...`" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
<input v-model="peersSearchTerm" type="text" :placeholder="`Search ${peersCount} recent announces...`" class="input-field">
|
||||
</div>
|
||||
|
||||
<!-- peers -->
|
||||
@@ -88,8 +126,8 @@
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ peer.custom_display_name ?? peer.display_name }}</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-gray-900 dark:text-gray-100 truncate" :title="peer.custom_display_name ?? peer.display_name">{{ peer.custom_display_name ?? peer.display_name }}</div>
|
||||
<div class="flex space-x-1 text-gray-500 dark:text-gray-400 text-sm">
|
||||
|
||||
<!-- time ago -->
|
||||
@@ -150,15 +188,35 @@ import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: 'MessagesSidebar',
|
||||
components: {MaterialDesignIcon},
|
||||
emits: ["conversation-click", "peer-click", "conversation-search-changed", "conversation-filter-changed"],
|
||||
props: {
|
||||
peers: Object,
|
||||
conversations: Array,
|
||||
selectedDestinationHash: String,
|
||||
conversationSearchTerm: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
filterUnreadOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterFailedOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterHasAttachmentsOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tab: "conversations",
|
||||
conversationsSearchTerm: "",
|
||||
peersSearchTerm: "",
|
||||
};
|
||||
},
|
||||
@@ -172,16 +230,23 @@ export default {
|
||||
formatTimeAgo: function(datetimeString) {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
},
|
||||
onConversationSearchInput(event) {
|
||||
this.$emit("conversation-search-changed", event.target.value);
|
||||
},
|
||||
toggleFilter(filterKey) {
|
||||
this.$emit("conversation-filter-changed", filterKey);
|
||||
},
|
||||
filterChipClasses(isActive) {
|
||||
const base = "px-2 py-1 rounded-full text-xs font-semibold transition-colors";
|
||||
if (isActive) {
|
||||
return `${base} bg-blue-600 text-white dark:bg-blue-500`;
|
||||
}
|
||||
return `${base} bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-200`;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
searchedConversations() {
|
||||
return this.conversations.filter((conversation) => {
|
||||
const search = this.conversationsSearchTerm.toLowerCase();
|
||||
const matchesDisplayName = conversation.display_name.toLowerCase().includes(search);
|
||||
const matchesCustomDisplayName = conversation.custom_display_name?.toLowerCase()?.includes(search) === true;
|
||||
const matchesDestinationHash = conversation.destination_hash.toLowerCase().includes(search);
|
||||
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
|
||||
});
|
||||
displayedConversations() {
|
||||
return this.conversations;
|
||||
},
|
||||
peersCount() {
|
||||
return Object.keys(this.peers).length;
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
<template>
|
||||
<div class="inline-flex rounded-md shadow-sm">
|
||||
<div class="relative inline-flex rounded-xl shadow-sm">
|
||||
<!-- 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']">
|
||||
<span v-if="isSendingMessage">Sending...</span>
|
||||
<span v-else class="space-x-1">
|
||||
<span>Send</span>
|
||||
<span v-if="deliveryMethod === 'direct'">(Direct Link)</span>
|
||||
<span v-if="deliveryMethod === 'opportunistic'">(Opportunistic)</span>
|
||||
<span v-if="deliveryMethod === 'propagated'">(Propagated)</span>
|
||||
<button @click="send" :disabled="!canSendMessage" type="button" class="inline-flex items-center gap-2 rounded-l-xl px-4 py-2.5 text-sm font-semibold text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" :class="[ canSendMessage ? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500' : 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 cursor-not-allowed']">
|
||||
<svg v-if="!isSendingMessage" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
</svg>
|
||||
<span v-if="isSendingMessage" class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Sending...
|
||||
</span>
|
||||
<span v-else>
|
||||
<span v-if="deliveryMethod === 'direct'">Send (Direct)</span>
|
||||
<span v-else-if="deliveryMethod === 'opportunistic'">Send (Opportunistic)</span>
|
||||
<span v-else-if="deliveryMethod === 'propagated'">Send (Propagated)</span>
|
||||
<span v-else>Send</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="relative">
|
||||
<!-- dropdown button -->
|
||||
<button @click="showMenu" :disabled="!canSendMessage" type="button" class="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']">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" data-slot="icon">
|
||||
<button @click="showMenu" :disabled="!canSendMessage" type="button" class="border-l relative inline-flex items-center justify-center rounded-r-xl px-2.5 py-2.5 text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" :class="[ canSendMessage ? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500 border-blue-700 dark:border-blue-800' : 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 border-gray-500 dark:border-zinc-600 cursor-not-allowed']">
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -25,12 +34,12 @@
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95">
|
||||
<div v-if="isShowingMenu" v-click-outside="hideMenu" class="absolute bottom-0 -ml-11 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">
|
||||
<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('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('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('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(null)" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap border-b border-gray-100 dark:border-zinc-800">Send Automatically</button>
|
||||
<button @click="setDeliveryMethod('direct')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Send over Direct Link</button>
|
||||
<button @click="setDeliveryMethod('opportunistic')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Send Opportunistically</button>
|
||||
<button @click="setDeliveryMethod('propagated')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Send to Propagation Node</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -3,38 +3,41 @@
|
||||
<!-- network -->
|
||||
<div id="network" class="w-full h-full"></div>
|
||||
<!-- controls -->
|
||||
<div class="absolute flex bottom-0 left-0 bg-gray-100 dark:bg-zinc-900 p-2">
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow min-w-52">
|
||||
<div @click="isShowingControls = !isShowingControls" class="flex text-gray-700 dark:text-gray-300 p-2 cursor-pointer">
|
||||
<div class="my-auto">Reticulum Network</div>
|
||||
<div class="flex ml-auto">
|
||||
<button
|
||||
@click.stop="update"
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-1 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-600"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 text-white">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="absolute bottom-4 left-4 z-10">
|
||||
<div class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-2xl shadow-lg overflow-hidden min-w-[240px]">
|
||||
<div @click="isShowingControls = !isShowingControls" class="flex items-center px-4 py-3 border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors">
|
||||
<div class="flex-1 font-semibold text-gray-900 dark:text-zinc-100">Reticulum Network</div>
|
||||
<button
|
||||
@click.stop="update"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white shadow-sm transition-colors"
|
||||
:disabled="isUpdating"
|
||||
>
|
||||
<svg v-if="!isUpdating" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
<svg v-else class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isShowingControls" class="divide-y dark:divide-zinc-700 text-gray-900 dark:text-white border-t border-gray-300 dark:border-zinc-700">
|
||||
<div class="px-1 py-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
v-model="autoReload"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-900 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-800"
|
||||
>
|
||||
</div>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Auto Update (5 sec)</label>
|
||||
</div>
|
||||
<div v-if="isShowingControls" class="px-4 py-3 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="autoReload"
|
||||
type="checkbox"
|
||||
id="auto-reload"
|
||||
class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-white dark:bg-zinc-900 text-blue-600 focus:ring-2 focus:ring-blue-500/50 focus:ring-offset-0"
|
||||
>
|
||||
<label for="auto-reload" class="text-sm font-medium text-gray-900 dark:text-zinc-100 cursor-pointer">Auto Update (5 sec)</label>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<div class="text-black dark:text-white">Interfaces</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">{{ onlineInterfaces.length }} Online, {{ offlineInterfaces.length }} Offline</div>
|
||||
<div class="pt-2 border-t border-gray-200 dark:border-zinc-800">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-zinc-100 mb-1">Interfaces</div>
|
||||
<div class="text-xs text-gray-600 dark:text-zinc-400">
|
||||
<span class="text-green-600 dark:text-green-400 font-medium">{{ onlineInterfaces.length }}</span> Online,
|
||||
<span class="text-red-600 dark:text-red-400 font-medium">{{ offlineInterfaces.length }}</span> Offline
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,7 +48,24 @@
|
||||
<style>
|
||||
.vis-tooltip {
|
||||
color: white !important;
|
||||
background: rgba(0, 0, 0, 0.75) !important;
|
||||
background: rgba(0, 0, 0, 0.85) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
font-size: 0.875rem !important;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
|
||||
.dark .vis-tooltip {
|
||||
background: rgba(24, 24, 27, 0.95) !important;
|
||||
border: 1px solid rgba(63, 63, 70, 0.5) !important;
|
||||
}
|
||||
|
||||
#network {
|
||||
background-color: rgb(249, 250, 251);
|
||||
}
|
||||
|
||||
.dark #network {
|
||||
background-color: rgb(9, 9, 11);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,6 +73,7 @@
|
||||
import "vis-network/styles/vis-network.css";
|
||||
import { Network } from "vis-network";
|
||||
import { DataSet } from "vis-data";
|
||||
import * as mdi from "@mdi/js";
|
||||
import Utils from "../../js/Utils";
|
||||
export default {
|
||||
name: 'NetworkVisualiser',
|
||||
@@ -62,12 +83,15 @@ export default {
|
||||
autoReload: false,
|
||||
reloadInterval: null,
|
||||
isShowingControls: true,
|
||||
isUpdating: false,
|
||||
interfaces: [],
|
||||
pathTable: [],
|
||||
announces: {},
|
||||
conversations: {},
|
||||
network: null,
|
||||
nodes: new DataSet(),
|
||||
edges: new DataSet(),
|
||||
iconCache: {},
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
@@ -118,6 +142,70 @@ export default {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async getConversations() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
|
||||
this.conversations = {};
|
||||
for(const conversation of response.data.conversations){
|
||||
this.conversations[conversation.destination_hash] = conversation;
|
||||
}
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async createIconImage(iconName, foregroundColor, backgroundColor, size = 32) {
|
||||
const cacheKey = `${iconName}-${foregroundColor}-${backgroundColor}-${size}`;
|
||||
if(this.iconCache[cacheKey]){
|
||||
return this.iconCache[cacheKey];
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// draw background circle
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, size / 2 - 1, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
// load MDI icon SVG
|
||||
const iconSvg = this.getMdiIconSvg(iconName, foregroundColor);
|
||||
const img = new Image();
|
||||
const svgBlob = new Blob([iconSvg], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, size * 0.2, size * 0.2, size * 0.6, size * 0.6);
|
||||
URL.revokeObjectURL(url);
|
||||
const dataUrl = canvas.toDataURL();
|
||||
this.iconCache[cacheKey] = dataUrl;
|
||||
resolve(dataUrl);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const dataUrl = canvas.toDataURL();
|
||||
this.iconCache[cacheKey] = dataUrl;
|
||||
resolve(dataUrl);
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
},
|
||||
getMdiIconSvg(iconName, foregroundColor) {
|
||||
const mdiIconName = "mdi" + iconName.split("-").map((word) => {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}).join("");
|
||||
|
||||
const iconPath = mdi[mdiIconName] || mdi["mdiAccountOutline"];
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${foregroundColor}" d="${iconPath}"/></svg>`;
|
||||
},
|
||||
async createMdiIconImage(iconName, size = 32) {
|
||||
const foregroundColor = '#ffffff';
|
||||
const backgroundColor = '#6b7280';
|
||||
return await this.createIconImage(iconName, foregroundColor, backgroundColor, size);
|
||||
},
|
||||
async init() {
|
||||
|
||||
// create network ui
|
||||
@@ -135,11 +223,23 @@ export default {
|
||||
},
|
||||
nodes: {
|
||||
color: {
|
||||
border: "#000000",
|
||||
border: "#e5e7eb",
|
||||
highlight: {
|
||||
border: "#000000",
|
||||
border: "#3b82f6",
|
||||
},
|
||||
},
|
||||
font: {
|
||||
color: "#111827",
|
||||
size: 14,
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
},
|
||||
},
|
||||
edges: {
|
||||
color: {
|
||||
color: "#9ca3af",
|
||||
highlight: "#3b82f6",
|
||||
},
|
||||
width: 2,
|
||||
},
|
||||
physics: {
|
||||
barnesHut: {
|
||||
@@ -256,14 +356,24 @@ export default {
|
||||
},
|
||||
async update() {
|
||||
|
||||
await this.getConfig();
|
||||
await this.getInterfaceStats();
|
||||
await this.getPathTable();
|
||||
await this.getAnnounces();
|
||||
this.isUpdating = true;
|
||||
try {
|
||||
await this.getConfig();
|
||||
await this.getInterfaceStats();
|
||||
await this.getPathTable();
|
||||
await this.getAnnounces();
|
||||
await this.getConversations();
|
||||
} finally {
|
||||
this.isUpdating = false;
|
||||
}
|
||||
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
const fontColor = isDarkMode ? "#f4f4f5" : "#111827";
|
||||
const fontBackground = isDarkMode ? "rgba(24, 24, 27, 0.9)" : "rgba(255, 255, 255, 0.9)";
|
||||
|
||||
// add me
|
||||
nodes.push({
|
||||
id: "me",
|
||||
@@ -275,8 +385,8 @@ export default {
|
||||
`Identity: ${this.config?.identity_hash ?? 'Unknown'}`,
|
||||
].join("\n"),
|
||||
font: {
|
||||
color: "#000000",
|
||||
background: "#ffffff",
|
||||
color: fontColor,
|
||||
background: fontBackground,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -305,8 +415,8 @@ export default {
|
||||
].join("\n"),
|
||||
size: 30,
|
||||
font: {
|
||||
color: "#000000",
|
||||
background: '#ffffff',
|
||||
color: fontColor,
|
||||
background: fontBackground,
|
||||
},
|
||||
shape: "circularImage",
|
||||
image: entry.status ? "/assets/images/network-visualiser/interface_connected.png" : "/assets/images/network-visualiser/interface_disconnected.png",
|
||||
@@ -322,12 +432,9 @@ export default {
|
||||
id: `${entry.parent_interface_name}~${entry.name}`,
|
||||
from: entry.parent_interface_name,
|
||||
to: entry.name,
|
||||
color: "transparent",
|
||||
color: entry.status ? "#22c55e" : "#ef4444",
|
||||
width: 3,
|
||||
length: 300,
|
||||
background: {
|
||||
enabled: true,
|
||||
color: entry.status ? "#22c55e" : "#ef4444",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// add edge from me to interface
|
||||
@@ -335,12 +442,9 @@ export default {
|
||||
id: `me~${entry.name}`,
|
||||
from: "me",
|
||||
to: entry.name,
|
||||
color: "transparent",
|
||||
color: entry.status ? "#22c55e" : "#ef4444",
|
||||
width: 3,
|
||||
length: 300,
|
||||
background: {
|
||||
enabled: true,
|
||||
color: entry.status ? "#22c55e" : "#ef4444",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,9 +479,22 @@ export default {
|
||||
if(announce.aspect === "lxmf.delivery"){
|
||||
|
||||
const name = announce.custom_display_name ?? announce.display_name;
|
||||
const conversation = this.conversations[announce.destination_hash];
|
||||
|
||||
node.shape = "circularImage";
|
||||
node.image = entry.hops === 1 ? "/assets/images/network-visualiser/user_1hop.png" : "/assets/images/network-visualiser/user.png";
|
||||
|
||||
if(conversation?.lxmf_user_icon){
|
||||
const iconImage = await this.createIconImage(
|
||||
conversation.lxmf_user_icon.icon_name,
|
||||
conversation.lxmf_user_icon.foreground_colour,
|
||||
conversation.lxmf_user_icon.background_colour,
|
||||
40
|
||||
);
|
||||
node.image = iconImage;
|
||||
node.size = 30;
|
||||
} else {
|
||||
node.image = entry.hops === 1 ? "/assets/images/network-visualiser/user_1hop.png" : "/assets/images/network-visualiser/user.png";
|
||||
}
|
||||
|
||||
node.label = name;
|
||||
node.title = [
|
||||
@@ -423,7 +540,8 @@ export default {
|
||||
id: `${entry.interface}~${entry.hash}`,
|
||||
from: entry.interface,
|
||||
to: entry.hash,
|
||||
color: "gray",
|
||||
color: isDarkMode ? "#71717a" : "#9ca3af",
|
||||
width: 2,
|
||||
});
|
||||
|
||||
}
|
||||
@@ -504,3 +622,4 @@ export default {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<!-- nomadnetwork sidebar -->
|
||||
<NomadNetworkSidebar
|
||||
v-if="!isPopoutMode"
|
||||
:nodes="nodes"
|
||||
:favourites="favourites"
|
||||
:selected-destination-hash="selectedNode?.destination_hash"
|
||||
@@ -11,7 +12,7 @@
|
||||
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<!-- node -->
|
||||
<div v-if="selectedNode" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
|
||||
<div v-if="selectedNode" class="flex flex-col h-full min-h-0 bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
|
||||
<!-- header -->
|
||||
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
|
||||
|
||||
@@ -38,9 +39,9 @@
|
||||
</div>
|
||||
|
||||
<!-- node info -->
|
||||
<div class="my-auto dark:text-gray-100">
|
||||
<span class="font-semibold">{{ selectedNode.display_name }}</span>
|
||||
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
|
||||
<div class="my-auto dark:text-gray-100 flex-1 min-w-0 flex items-baseline gap-1">
|
||||
<span class="font-semibold truncate inline-block max-w-xs sm:max-w-sm" :title="selectedNode.display_name">{{ selectedNode.display_name }}</span>
|
||||
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer whitespace-nowrap"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
|
||||
</div>
|
||||
|
||||
<!-- identify button -->
|
||||
@@ -56,6 +57,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- popout button -->
|
||||
<div class="my-auto mr-2">
|
||||
<div @click="openNomadnetPopout" class="cursor-pointer">
|
||||
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path d="M17 3.75h3.25A.75.75 0 0 1 21 4.5v3.25a.75.75 0 0 1-1.5 0V6.31l-4.97 4.97a.75.75 0 1 1-1.06-1.06l4.97-4.97H17a.75.75 0 0 1 0-1.5Z"/>
|
||||
<path d="M5.25 6A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75v-6a.75.75 0 0 0-1.5 0v6c0 .414-.336.75-.75.75H5.25a.75.75 0 0 1-.75-.75V8.25c0-.414.336-.75.75-.75h6a.75.75 0 0 0 0-1.5h-6Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- close button -->
|
||||
<div class="my-auto mr-2">
|
||||
<div @click="onCloseNodeViewer" class="cursor-pointer">
|
||||
@@ -103,7 +118,7 @@
|
||||
</div>
|
||||
|
||||
<!-- page content -->
|
||||
<div class="h-full overflow-y-scroll p-3 bg-black text-white nodeContainer">
|
||||
<div class="flex-1 overflow-y-auto p-3 bg-black text-white nodeContainer">
|
||||
<div class="flex" v-if="isLoadingNodePage">
|
||||
<div class="my-auto">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@@ -111,7 +126,10 @@
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto">Loading {{ nodePageProgress }}%</div>
|
||||
<div class="my-auto flex-1">Loading {{ nodePageProgress }}%</div>
|
||||
<button @click="cancelPageDownload" type="button" class="my-auto text-white bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 rounded px-3 py-1 text-sm font-semibold cursor-pointer ml-3">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<pre v-else v-html="renderedNodePageContent()" class="h-full break-words whitespace-pre-wrap"></pre>
|
||||
</div>
|
||||
@@ -124,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>
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
@@ -179,6 +205,7 @@ import DialogUtils from "../../js/DialogUtils";
|
||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
|
||||
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||
import Utils from "../../js/Utils";
|
||||
|
||||
export default {
|
||||
name: 'NomadNetworkPage',
|
||||
@@ -209,10 +236,16 @@ export default {
|
||||
nodePageProgress: 0,
|
||||
nodePagePathHistory: [],
|
||||
nodePageCache: {},
|
||||
currentPageDownloadId: null,
|
||||
|
||||
isDownloadingNodeFile: false,
|
||||
nodeFilePath: null,
|
||||
nodeFileProgress: 0,
|
||||
nodeFileDownloadStartTime: null,
|
||||
nodeFileLastProgressTime: null,
|
||||
nodeFileLastProgressValue: 0,
|
||||
nodeFileDownloadSpeed: null,
|
||||
currentFileDownloadId: null,
|
||||
|
||||
nomadnetPageDownloadCallbacks: {},
|
||||
nomadnetFileDownloadCallbacks: {},
|
||||
@@ -256,7 +289,27 @@ export default {
|
||||
}, 5000);
|
||||
|
||||
},
|
||||
computed: {
|
||||
popoutRouteType() {
|
||||
if(this.$route?.meta?.popoutType){
|
||||
return this.$route.meta.popoutType;
|
||||
}
|
||||
return this.$route?.query?.popout ?? this.getHashPopoutValue();
|
||||
},
|
||||
isPopoutMode() {
|
||||
return this.popoutRouteType === "nomad";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openNomadnetPopout() {
|
||||
if (!this.selectedNode) {
|
||||
return;
|
||||
}
|
||||
const destinationHash = this.selectedNode.destination_hash || "";
|
||||
const encodedHash = encodeURIComponent(destinationHash);
|
||||
const url = `${window.location.origin}${window.location.pathname}#/popout/nomadnetwork/${encodedHash}`;
|
||||
window.open(url, "_blank", "width=1100,height=800,noopener");
|
||||
},
|
||||
onElementClick(event) {
|
||||
|
||||
// find the closest ancestor (or the clicked element itself) with data-action="openNode"
|
||||
@@ -287,6 +340,13 @@ export default {
|
||||
|
||||
// get data from server
|
||||
const nomadnetPageDownload = json.nomadnet_page_download;
|
||||
const downloadId = json.download_id;
|
||||
|
||||
// handle started status
|
||||
if(nomadnetPageDownload.status === "started"){
|
||||
this.currentPageDownloadId = downloadId;
|
||||
return;
|
||||
}
|
||||
|
||||
// find download callbacks
|
||||
const getNomadnetPageDownloadCallbackKey = this.getNomadnetPageDownloadCallbackKey(nomadnetPageDownload.destination_hash, nomadnetPageDownload.page_path);
|
||||
@@ -300,6 +360,7 @@ export default {
|
||||
if(nomadnetPageDownload.status === "success" && nomadnetPageDownloadCallback.onSuccessCallback){
|
||||
nomadnetPageDownloadCallback.onSuccessCallback(nomadnetPageDownload.page_content);
|
||||
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
||||
this.currentPageDownloadId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -307,6 +368,7 @@ export default {
|
||||
if(nomadnetPageDownload.status === "failure" && nomadnetPageDownloadCallback.onFailureCallback){
|
||||
nomadnetPageDownloadCallback.onFailureCallback(nomadnetPageDownload.failure_reason);
|
||||
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
|
||||
this.currentPageDownloadId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -323,6 +385,13 @@ export default {
|
||||
|
||||
// get data from server
|
||||
const nomadnetFileDownload = json.nomadnet_file_download;
|
||||
const downloadId = json.download_id;
|
||||
|
||||
// handle started status
|
||||
if(nomadnetFileDownload.status === "started"){
|
||||
this.currentFileDownloadId = downloadId;
|
||||
return;
|
||||
}
|
||||
|
||||
// find download callbacks
|
||||
const getNomadnetFileDownloadCallbackKey = this.getNomadnetFileDownloadCallbackKey(nomadnetFileDownload.destination_hash, nomadnetFileDownload.file_path);
|
||||
@@ -336,6 +405,7 @@ export default {
|
||||
if(nomadnetFileDownload.status === "success" && nomadnetFileDownloadCallback.onSuccessCallback){
|
||||
nomadnetFileDownloadCallback.onSuccessCallback(nomadnetFileDownload.file_name, nomadnetFileDownload.file_bytes);
|
||||
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
||||
this.currentFileDownloadId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -343,6 +413,7 @@ export default {
|
||||
if(nomadnetFileDownload.status === "failure" && nomadnetFileDownloadCallback.onFailureCallback){
|
||||
nomadnetFileDownloadCallback.onFailureCallback(nomadnetFileDownload.failure_reason);
|
||||
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
|
||||
this.currentFileDownloadId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -355,6 +426,26 @@ export default {
|
||||
break;
|
||||
|
||||
}
|
||||
case 'nomadnet.download.cancelled': {
|
||||
// handle download cancellation
|
||||
const downloadId = json.download_id;
|
||||
|
||||
// clear page download if it matches
|
||||
if(this.currentPageDownloadId === downloadId){
|
||||
this.currentPageDownloadId = null;
|
||||
this.isLoadingNodePage = false;
|
||||
this.nodePageContent = "Download cancelled";
|
||||
}
|
||||
|
||||
// clear file download if it matches
|
||||
if(this.currentFileDownloadId === downloadId){
|
||||
this.currentFileDownloadId = null;
|
||||
this.isDownloadingNodeFile = false;
|
||||
this.nodeFileDownloadSpeed = null;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
onDestinationPathClick: function(path) {
|
||||
@@ -470,12 +561,17 @@ export default {
|
||||
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
|
||||
|
||||
// update current route
|
||||
this.$router.replace({
|
||||
name: "nomadnetwork",
|
||||
const routeName = this.isPopoutMode ? "nomadnetwork-popout" : "nomadnetwork";
|
||||
const routeOptions = {
|
||||
name: routeName,
|
||||
params: {
|
||||
destinationHash: destinationHash,
|
||||
},
|
||||
});
|
||||
};
|
||||
if(!this.isPopoutMode && this.$route?.query){
|
||||
routeOptions.query = { ...this.$route.query };
|
||||
}
|
||||
this.$router.replace(routeOptions);
|
||||
|
||||
// get new sequence for this page load
|
||||
const seq = ++this.nodePageRequestSequence;
|
||||
@@ -726,8 +822,9 @@ export default {
|
||||
if(url.startsWith("lxmf@")){
|
||||
const destinationHash = url.replace("lxmf@", "");
|
||||
if(destinationHash.length === 32){
|
||||
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
|
||||
await this.$router.push({
|
||||
name: "messages",
|
||||
name: routeName,
|
||||
params: {
|
||||
destinationHash: destinationHash,
|
||||
},
|
||||
@@ -756,26 +853,72 @@ export default {
|
||||
this.isDownloadingNodeFile = true;
|
||||
this.nodeFilePath = parsedUrl.path.split("/").pop();
|
||||
this.nodeFileProgress = 0;
|
||||
this.nodeFileDownloadStartTime = Date.now();
|
||||
this.nodeFileLastProgressTime = Date.now();
|
||||
this.nodeFileLastProgressValue = 0;
|
||||
this.nodeFileDownloadSpeed = null;
|
||||
|
||||
// start file download
|
||||
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
|
||||
this.isDownloadingNodeFile = false;
|
||||
|
||||
// download file to browser
|
||||
this.downloadFileFromBase64(fileName, fileBytesBase64);
|
||||
|
||||
// Clear speed after a moment
|
||||
setTimeout(() => {
|
||||
this.nodeFileDownloadSpeed = null;
|
||||
}, 2000);
|
||||
|
||||
}, (failureReason) => {
|
||||
|
||||
// no longer downloading
|
||||
this.isDownloadingNodeFile = false;
|
||||
this.nodeFileDownloadSpeed = null;
|
||||
|
||||
// show error message
|
||||
DialogUtils.alert(`Failed to download file: ${failureReason}`);
|
||||
|
||||
}, (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;
|
||||
@@ -829,6 +972,9 @@ export default {
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
|
||||
|
||||
},
|
||||
formatBytesPerSecond: function(bytesPerSecond) {
|
||||
return Utils.formatBytesPerSecond(bytesPerSecond);
|
||||
},
|
||||
onNodeClick: function(node) {
|
||||
|
||||
// update selected node
|
||||
@@ -877,10 +1023,18 @@ export default {
|
||||
// clear selected node
|
||||
this.selectedNode = null;
|
||||
|
||||
if(this.isPopoutMode){
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// update current route
|
||||
this.$router.replace({
|
||||
name: "nomadnetwork",
|
||||
});
|
||||
const routeName = this.isPopoutMode ? "nomadnetwork-popout" : "nomadnetwork";
|
||||
const routeOptions = { name: routeName };
|
||||
if(!this.isPopoutMode && this.$route?.query){
|
||||
routeOptions.query = { ...this.$route.query };
|
||||
}
|
||||
this.$router.replace(routeOptions);
|
||||
|
||||
},
|
||||
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
|
||||
@@ -925,6 +1079,11 @@ export default {
|
||||
DialogUtils.alert(e.response?.data?.message ?? "Failed to identify!");
|
||||
}
|
||||
},
|
||||
getHashPopoutValue() {
|
||||
const hash = window.location.hash || "";
|
||||
const match = hash.match(/popout=([^&]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
|
||||
try {
|
||||
|
||||
@@ -975,6 +1134,22 @@ export default {
|
||||
renderedNodePageContent() {
|
||||
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
|
||||
},
|
||||
cancelPageDownload() {
|
||||
if(this.currentPageDownloadId !== null){
|
||||
WebSocketConnection.send(JSON.stringify({
|
||||
"type": "nomadnet.download.cancel",
|
||||
"download_id": this.currentPageDownloadId,
|
||||
}));
|
||||
}
|
||||
},
|
||||
cancelFileDownload() {
|
||||
if(this.currentFileDownloadId !== null){
|
||||
WebSocketConnection.send(JSON.stringify({
|
||||
"type": "nomadnet.download.cancel",
|
||||
"download_id": this.currentFileDownloadId,
|
||||
}));
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,153 +1,90 @@
|
||||
<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">
|
||||
|
||||
<!-- tabs -->
|
||||
<div class="bg-white dark:bg-zinc-950 border-b border-r border-gray-200 dark:border-zinc-700">
|
||||
<div class="-mb-px flex">
|
||||
<div @click="tab = 'favourites'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'favourites' ? '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']">Favourites</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>
|
||||
<div class="flex">
|
||||
<button @click="tab = 'favourites'" type="button" class="sidebar-tab" :class="{ 'sidebar-tab--active': tab === 'favourites' }">
|
||||
Favourites
|
||||
</button>
|
||||
<button @click="tab = 'announces'" type="button" class="sidebar-tab" :class="{ 'sidebar-tab--active': tab === 'announces' }">
|
||||
Announces
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- favourites -->
|
||||
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden">
|
||||
|
||||
<!-- search -->
|
||||
<div v-if="favourites.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
|
||||
<input v-model="favouritesSearchTerm" type="text" :placeholder="`Search ${favourites.length} Favourites...`" 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">
|
||||
<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>
|
||||
|
||||
<!-- peers -->
|
||||
<div class="flex h-full overflow-y-auto">
|
||||
<div v-if="searchedFavourites.length > 0" class="w-full">
|
||||
<div @click="onFavouriteClick(favourite)" v-for="favourite of searchedFavourites" class="flex cursor-pointer p-2 border-l-2" :class="[ favourite.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="bg-gray-200 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 p-2 rounded">
|
||||
<MaterialDesignIcon icon-name="server-network-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ favourite.display_name }}</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatDestinationHash(favourite.destination_hash) }}</div>
|
||||
</div>
|
||||
<div class="ml-auto my-auto">
|
||||
<DropDownMenu>
|
||||
<template v-slot:button>
|
||||
<IconButton class="bg-transparent dark:bg-transparent">
|
||||
<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="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</template>
|
||||
<template v-slot:items>
|
||||
|
||||
<!-- rename button -->
|
||||
<DropDownMenuItem @click="onRenameFavourite(favourite)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M5.25 2.25a3 3 0 0 0-3 3v4.318a3 3 0 0 0 .879 2.121l9.58 9.581c.92.92 2.39 1.186 3.548.428a18.849 18.849 0 0 0 5.441-5.44c.758-1.16.492-2.629-.428-3.548l-9.58-9.581a3 3 0 0 0-2.122-.879H5.25ZM6.375 7.5a1.125 1.125 0 1 0 0-2.25 1.125 1.125 0 0 0 0 2.25Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Rename Favourite</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- remove favourite button -->
|
||||
<div>
|
||||
<DropDownMenuItem @click="onRemoveFavourite(favourite)">
|
||||
<svg class="size-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-red-500">Remove Favourite</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</DropDownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mx-auto my-auto text-center leading-5">
|
||||
|
||||
<!-- no favourites at all -->
|
||||
<div v-if="favourites.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div class="mx-auto mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="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 class="font-semibold">No Favourites</div>
|
||||
<div>Discover nodes on the Announces tab.</div>
|
||||
</div>
|
||||
|
||||
<!-- is searching, but no results -->
|
||||
<div v-if="favouritesSearchTerm !== '' && favourites.length > 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div class="mx-auto mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold">No Search Results</div>
|
||||
<div>Your search didn't match any Favourites!</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- announces -->
|
||||
<div v-if="tab === 'announces'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
|
||||
<!-- search -->
|
||||
<div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800">
|
||||
<input
|
||||
v-model="nodesSearchTerm"
|
||||
type="text"
|
||||
:placeholder="`Search ${nodesCount} Nodes...`"
|
||||
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"
|
||||
>
|
||||
</div>
|
||||
<!-- nodes -->
|
||||
<div class="flex h-full overflow-y-auto">
|
||||
<div v-if="searchedNodes.length > 0" class="w-full">
|
||||
<div
|
||||
@click="onNodeClick(node)"
|
||||
v-for="node of searchedNodes"
|
||||
class="flex cursor-pointer p-2 border-l-2"
|
||||
:class="[
|
||||
node.destination_hash === selectedDestinationHash
|
||||
? 'bg-gray-100 dark:bg-zinc-800 border-blue-500'
|
||||
: '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'
|
||||
<div class="flex-1 overflow-y-auto px-2 pb-4">
|
||||
<div v-if="searchedFavourites.length > 0" class="space-y-2 pt-2">
|
||||
<div
|
||||
v-for="favourite of searchedFavourites"
|
||||
:key="favourite.destination_hash"
|
||||
@click="onFavouriteClick(favourite)"
|
||||
class="favourite-card"
|
||||
:class="[
|
||||
favourite.destination_hash === selectedDestinationHash ? 'favourite-card--active' : '',
|
||||
draggingFavouriteHash === favourite.destination_hash ? 'favourite-card--dragging' : ''
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="onFavouriteDragStart($event, favourite)"
|
||||
@dragover.prevent="onFavouriteDragOver($event)"
|
||||
@drop.prevent="onFavouriteDrop($event, favourite)"
|
||||
@dragend="onFavouriteDragEnd"
|
||||
>
|
||||
<div class="my-auto mr-2">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 p-2 rounded">
|
||||
<MaterialDesignIcon icon-name="server-network-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
<div class="favourite-card__icon">
|
||||
<MaterialDesignIcon icon-name="server-network" class="w-5 h-5"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white truncate" :title="favourite.display_name">{{ favourite.display_name }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ formatDestinationHash(favourite.destination_hash) }}</div>
|
||||
</div>
|
||||
<DropDownMenu>
|
||||
<template #button>
|
||||
<IconButton class="bg-transparent dark:bg-transparent w-8 h-8 p-0 flex items-center justify-center">
|
||||
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5"/>
|
||||
</IconButton>
|
||||
</template>
|
||||
<template #items>
|
||||
<DropDownMenuItem @click="onRenameFavourite(favourite)">
|
||||
<MaterialDesignIcon icon-name="pencil" class="w-5 h-5"/>
|
||||
<span>Rename</span>
|
||||
</DropDownMenuItem>
|
||||
<DropDownMenuItem @click="onRemoveFavourite(favourite)">
|
||||
<MaterialDesignIcon icon-name="trash-can" class="w-5 h-5 text-red-500"/>
|
||||
<span class="text-red-500">Remove</span>
|
||||
</DropDownMenuItem>
|
||||
</template>
|
||||
</DropDownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<MaterialDesignIcon icon-name="star-outline" class="w-8 h-8"/>
|
||||
<div class="font-semibold">No favourites</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Add nodes from the announces tab.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 flex flex-col min-h-0">
|
||||
<div class="p-3 border-b border-gray-200 dark:border-zinc-800">
|
||||
<input v-model="nodesSearchTerm" type="text" placeholder="Search announces" class="input-field"/>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-2 pb-4">
|
||||
<div v-if="searchedNodes.length > 0" class="space-y-2 pt-2">
|
||||
<div v-for="node of searchedNodes" :key="node.destination_hash" @click="onNodeClick(node)" class="announce-card" :class="{ 'announce-card--active': node.destination_hash === selectedDestinationHash }">
|
||||
<div class="announce-card__icon">
|
||||
<MaterialDesignIcon icon-name="satellite-uplink" class="w-5 h-5"/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ node.display_name }}</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatTimeAgo(node.updated_at) }}</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="mx-auto my-auto text-center leading-5">
|
||||
<!-- no nodes at all -->
|
||||
<div v-if="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="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 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>
|
||||
@@ -176,8 +113,22 @@ export default {
|
||||
tab: "favourites",
|
||||
favouritesSearchTerm: "",
|
||||
nodesSearchTerm: "",
|
||||
favouritesOrder: [],
|
||||
draggingFavouriteHash: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadFavouriteOrder();
|
||||
this.ensureFavouriteOrder();
|
||||
},
|
||||
watch: {
|
||||
favourites: {
|
||||
handler() {
|
||||
this.ensureFavouriteOrder();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onNodeClick(node) {
|
||||
this.$emit("node-click", node);
|
||||
@@ -191,6 +142,67 @@ export default {
|
||||
onRemoveFavourite(favourite) {
|
||||
this.$emit("remove-favourite", favourite);
|
||||
},
|
||||
loadFavouriteOrder() {
|
||||
try {
|
||||
const stored = localStorage.getItem("meshchat.nomadnet.favourites");
|
||||
if(stored){
|
||||
this.favouritesOrder = JSON.parse(stored);
|
||||
}
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
persistFavouriteOrder() {
|
||||
localStorage.setItem("meshchat.nomadnet.favourites", JSON.stringify(this.favouritesOrder));
|
||||
},
|
||||
ensureFavouriteOrder() {
|
||||
const hashes = this.favourites.map((fav) => fav.destination_hash);
|
||||
const nextOrder = this.favouritesOrder.filter((hash) => hashes.includes(hash));
|
||||
hashes.forEach((hash) => {
|
||||
if(!nextOrder.includes(hash)){
|
||||
nextOrder.push(hash);
|
||||
}
|
||||
});
|
||||
if(JSON.stringify(nextOrder) !== JSON.stringify(this.favouritesOrder)){
|
||||
this.favouritesOrder = nextOrder;
|
||||
this.persistFavouriteOrder();
|
||||
}
|
||||
},
|
||||
onFavouriteDragStart(event, favourite) {
|
||||
try {
|
||||
if(event?.dataTransfer){
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", favourite.destination_hash);
|
||||
}
|
||||
} catch(e) {
|
||||
// ignore for browsers that prevent setting drag meta
|
||||
}
|
||||
this.draggingFavouriteHash = favourite.destination_hash;
|
||||
},
|
||||
onFavouriteDragOver(event) {
|
||||
if(event?.dataTransfer){
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
},
|
||||
onFavouriteDrop(event, targetFavourite) {
|
||||
if(!this.draggingFavouriteHash || this.draggingFavouriteHash === targetFavourite.destination_hash){
|
||||
return;
|
||||
}
|
||||
const fromIndex = this.favouritesOrder.indexOf(this.draggingFavouriteHash);
|
||||
const toIndex = this.favouritesOrder.indexOf(targetFavourite.destination_hash);
|
||||
if(fromIndex === -1 || toIndex === -1){
|
||||
return;
|
||||
}
|
||||
const updated = [...this.favouritesOrder];
|
||||
updated.splice(fromIndex, 1);
|
||||
updated.splice(toIndex, 0, this.draggingFavouriteHash);
|
||||
this.favouritesOrder = updated;
|
||||
this.persistFavouriteOrder();
|
||||
this.draggingFavouriteHash = null;
|
||||
},
|
||||
onFavouriteDragEnd() {
|
||||
this.draggingFavouriteHash = null;
|
||||
},
|
||||
formatTimeAgo: function(datetimeString) {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
},
|
||||
@@ -219,8 +231,13 @@ export default {
|
||||
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.favourites.filter((favourite) => {
|
||||
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;
|
||||
@@ -231,3 +248,34 @@ export default {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-tab {
|
||||
@apply w-1/2 py-3 text-sm font-semibold text-gray-500 dark:text-gray-400 border-b-2 border-transparent transition;
|
||||
}
|
||||
.sidebar-tab--active {
|
||||
@apply text-blue-600 border-blue-500 dark:text-blue-300 dark:border-blue-400;
|
||||
}
|
||||
.favourite-card {
|
||||
@apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500;
|
||||
}
|
||||
.favourite-card--active {
|
||||
@apply border-blue-500 dark:border-blue-400 bg-blue-50/60 dark:bg-blue-900/30;
|
||||
}
|
||||
.favourite-card__icon,
|
||||
.announce-card__icon {
|
||||
@apply w-10 h-10 rounded-xl bg-gray-100 dark:bg-zinc-800 flex items-center justify-center text-gray-500 dark:text-gray-300;
|
||||
}
|
||||
.favourite-card--dragging {
|
||||
@apply opacity-60 ring-2 ring-blue-300 dark:ring-blue-600;
|
||||
}
|
||||
.announce-card {
|
||||
@apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500;
|
||||
}
|
||||
.announce-card--active {
|
||||
@apply border-blue-500 dark:border-blue-400 bg-blue-50/70 dark:bg-blue-900/30;
|
||||
}
|
||||
.empty-state {
|
||||
@apply flex flex-col items-center justify-center text-center gap-2 text-gray-500 dark:text-gray-400 mt-20;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,59 +1,85 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="flex flex-col h-full space-y-2 p-2 overflow-y-auto">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900">
|
||||
<div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
|
||||
<div class="space-y-4 w-full max-w-4xl mx-auto">
|
||||
|
||||
<!-- appearance -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Ping</div>
|
||||
<div class="dark:divide-zinc-700 text-gray-900 dark:text-gray-100 p-2">
|
||||
Only lxmf.delivery destinations can be pinged.
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card space-y-5">
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Diagnostics</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">Ping Mesh Peers</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Only <code class="font-mono text-xs">lxmf.delivery</code> destinations respond to ping.</div>
|
||||
</div>
|
||||
|
||||
<!-- inputs -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Destination Hash</div>
|
||||
<div class="flex">
|
||||
<input v-model="destinationHash" type="text" placeholder="e.g: 7b746057a7294469799cd8d7d429676a" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="glass-label">Destination Hash</label>
|
||||
<input v-model="destinationHash" type="text" placeholder="e.g. 7b746057a7294469799cd8d7d429676a" class="input-field font-mono"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="glass-label">Ping Timeout (seconds)</label>
|
||||
<input v-model="timeout" type="number" min="1" class="input-field"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Ping Timeout (seconds)</div>
|
||||
<div class="flex">
|
||||
<input v-model="timeout" type="number" placeholder="Timeout" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2 space-x-1">
|
||||
<button v-if="!isRunning" @click="start" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
|
||||
Start
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button v-if="!isRunning" @click="start" type="button" class="primary-chip px-4 py-2 text-sm">
|
||||
<MaterialDesignIcon icon-name="play" class="w-4 h-4"/>
|
||||
Start Ping
|
||||
</button>
|
||||
<button v-if="isRunning" @click="stop" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
|
||||
<button v-else @click="stop" type="button" class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50">
|
||||
<MaterialDesignIcon icon-name="pause" class="w-4 h-4"/>
|
||||
Stop
|
||||
</button>
|
||||
<button @click="clear" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
|
||||
<button @click="clear" type="button" class="secondary-chip px-4 py-2 text-sm">
|
||||
<MaterialDesignIcon icon-name="broom" class="w-4 h-4"/>
|
||||
Clear Results
|
||||
</button>
|
||||
<button @click="dropPath" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
<button @click="dropPath" type="button" class="inline-flex items-center gap-2 rounded-full bg-red-600/90 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-500 transition">
|
||||
<MaterialDesignIcon icon-name="link-variant-remove" class="w-4 h-4"/>
|
||||
Drop Path
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-xs font-semibold">
|
||||
<span :class="[isRunning ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200' : 'bg-gray-200 text-gray-700 dark:bg-zinc-800 dark:text-gray-200', 'rounded-full px-3 py-1']">
|
||||
Status: {{ isRunning ? 'Running' : 'Idle' }}
|
||||
</span>
|
||||
<span v-if="lastPingSummary?.duration" class="rounded-full px-3 py-1 bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
Last RTT: {{ lastPingSummary.duration }}
|
||||
</span>
|
||||
<span v-if="lastPingSummary?.error" class="rounded-full px-3 py-1 bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200">
|
||||
Last Error: {{ lastPingSummary.error }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- results -->
|
||||
<div class="flex flex-col h-full bg-white dark:bg-zinc-800 rounded shadow overflow-hidden min-h-52">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Results</div>
|
||||
<div id="results" class="flex flex-col h-full bg-black text-white dark:bg-zinc-800 dark:text-gray-200 p-2 overflow-y-auto overflow-x-auto font-mono whitespace-nowrap">
|
||||
<div v-for="pingResult of pingResults" class="w-fit">{{ pingResult }}</div>
|
||||
<div class="glass-card flex flex-col min-h-[320px] space-y-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">Console Output</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Streaming seq responses in real time</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
seq #{{ seq }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="lastPingSummary && !lastPingSummary.error" class="flex flex-wrap gap-2 text-xs text-gray-700 dark:text-gray-200">
|
||||
<span v-if="lastPingSummary.hopsThere != null" class="stat-chip">Hops there: {{ lastPingSummary.hopsThere }}</span>
|
||||
<span v-if="lastPingSummary.hopsBack != null" class="stat-chip">Hops back: {{ lastPingSummary.hopsBack }}</span>
|
||||
<span v-if="lastPingSummary.rssi != null" class="stat-chip">RSSI {{ lastPingSummary.rssi }} dBm</span>
|
||||
<span v-if="lastPingSummary.snr != null" class="stat-chip">SNR {{ lastPingSummary.snr }} dB</span>
|
||||
<span v-if="lastPingSummary.quality != null" class="stat-chip">Quality {{ lastPingSummary.quality }}%</span>
|
||||
<span v-if="lastPingSummary.via" class="stat-chip">Interface {{ lastPingSummary.via }}</span>
|
||||
</div>
|
||||
|
||||
<div id="results" class="flex-1 overflow-y-auto rounded-2xl bg-black/80 text-emerald-300 font-mono text-xs p-3 space-y-1 shadow-inner border border-zinc-900">
|
||||
<div v-if="pingResults.length === 0" class="text-emerald-500/80">No pings yet. Start a run to collect RTT data.</div>
|
||||
<div v-for="(pingResult, index) in pingResults" :key="`${index}-${pingResult}`" class="whitespace-pre-wrap">{{ pingResult }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,9 +87,13 @@
|
||||
<script>
|
||||
import {CanceledError} from "axios";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: 'PingPage',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isRunning: false,
|
||||
@@ -72,6 +102,7 @@ export default {
|
||||
seq: 0,
|
||||
pingResults: [],
|
||||
abortController: null,
|
||||
lastPingSummary: null,
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
@@ -116,10 +147,13 @@ export default {
|
||||
},
|
||||
async stop() {
|
||||
this.isRunning = false;
|
||||
this.abortController.abort();
|
||||
if(this.abortController){
|
||||
this.abortController.abort();
|
||||
}
|
||||
},
|
||||
async clear() {
|
||||
this.pingResults = [];
|
||||
this.lastPingSummary = null;
|
||||
},
|
||||
async sleep(millis) {
|
||||
return new Promise((resolve, reject) => setTimeout(resolve, millis));
|
||||
@@ -168,6 +202,15 @@ export default {
|
||||
|
||||
// update ui
|
||||
this.addPingResult(info.join(" "));
|
||||
this.lastPingSummary = {
|
||||
duration: rttDurationString,
|
||||
hopsThere: pingResult.hops_there,
|
||||
hopsBack: pingResult.hops_back,
|
||||
rssi: pingResult.rssi,
|
||||
snr: pingResult.snr,
|
||||
quality: pingResult.quality,
|
||||
via: pingResult.receiving_interface,
|
||||
};
|
||||
|
||||
} catch(e) {
|
||||
|
||||
@@ -181,6 +224,9 @@ export default {
|
||||
// add ping error to results
|
||||
const message = e.response?.data?.message ?? e;
|
||||
this.addPingResult(`seq=${this.seq} error=${message}`);
|
||||
this.lastPingSummary = {
|
||||
error: typeof message === "string" ? message : JSON.stringify(message),
|
||||
};
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,43 +1,78 @@
|
||||
<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 -->
|
||||
<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">
|
||||
<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">
|
||||
<!-- search and sort -->
|
||||
<div v-if="propagationNodes.length > 0" class="flex flex-col sm:flex-row gap-2 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 px-4 py-3">
|
||||
<input v-model="searchTerm" type="text" :placeholder="`Search ${propagationNodes.length} Propagation Nodes...`" class="flex-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500">
|
||||
<select v-model="sortBy" class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2 shadow-sm transition-all min-w-[180px]">
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="name-desc">Sort by Name (Z-A)</option>
|
||||
<option value="recent">Sort by Recent</option>
|
||||
<option value="oldest">Sort by Oldest</option>
|
||||
<option value="preferred">Preferred First</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- propagation nodes -->
|
||||
<div class="h-full overflow-y-auto">
|
||||
<div v-if="searchedPropagationNodes.length > 0" class="p-2 space-y-2 w-full">
|
||||
<div v-for="propagationNode of searchedPropagationNodes" class="border dark:border-zinc-700 rounded bg-white dark:bg-zinc-800 shadow">
|
||||
<div class="p-1 flex">
|
||||
<div class="my-auto">
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ propagationNode.operator_display_name ?? "Unknown Operator" }}</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300"><{{ propagationNode.destination_hash }}></div>
|
||||
<div class="h-full overflow-y-auto px-4 py-4">
|
||||
<div v-if="paginatedNodes.length > 0" class="space-y-3 w-full">
|
||||
<div v-for="propagationNode of paginatedNodes" :key="propagationNode.destination_hash" class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-sm hover:shadow-md transition-shadow overflow-hidden" :class="{ 'ring-2 ring-blue-500 dark:ring-blue-400': config.lxmf_preferred_propagation_node_destination_hash === propagationNode.destination_hash }">
|
||||
<div class="p-4 flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="font-semibold text-gray-900 dark:text-zinc-100 truncate">{{ propagationNode.operator_display_name ?? "Unknown Operator" }}</div>
|
||||
<span v-if="config.lxmf_preferred_propagation_node_destination_hash === propagationNode.destination_hash" class="inline-flex items-center gap-1 rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs font-semibold text-blue-700 dark:text-blue-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Preferred
|
||||
</span>
|
||||
<span v-if="propagationNode.is_propagation_enabled === false" class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2 py-0.5 text-xs font-semibold text-red-700 dark:text-red-300">
|
||||
Disabled
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-zinc-400 font-mono truncate"><{{ propagationNode.destination_hash }}></div>
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-500 mt-1">Announced {{ formatTimeAgo(propagationNode.updated_at) }}</div>
|
||||
</div>
|
||||
<div class="ml-auto my-auto">
|
||||
<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">
|
||||
Stop Using Node
|
||||
<div class="flex-shrink-0">
|
||||
<button v-if="config.lxmf_preferred_propagation_node_destination_hash === propagationNode.destination_hash" @click="stopUsingPropagationNode" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
|
||||
Stop Using
|
||||
</button>
|
||||
<button v-else @click="usePropagationNode(propagationNode.destination_hash)" type="button" class="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
|
||||
</button>
|
||||
</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 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">
|
||||
|
||||
<!-- no propagation nodes at all -->
|
||||
@@ -49,8 +84,8 @@
|
||||
</div>
|
||||
<div class="font-semibold">No Propagation Nodes</div>
|
||||
<div>Check back later, once someone has announced.</div>
|
||||
<div class="mt-2">
|
||||
<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">
|
||||
<div class="mt-4">
|
||||
<button @click="loadPropagationNodes" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
@@ -83,10 +118,13 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
searchTerm: "",
|
||||
sortBy: "preferred",
|
||||
propagationNodes: [],
|
||||
config: {
|
||||
lxmf_preferred_propagation_node_destination_hash: null,
|
||||
},
|
||||
currentPage: 1,
|
||||
itemsPerPage: 20,
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
@@ -158,6 +196,14 @@ export default {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
searchTerm() {
|
||||
this.currentPage = 1;
|
||||
},
|
||||
sortBy() {
|
||||
this.currentPage = 1;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
searchedPropagationNodes() {
|
||||
return this.propagationNodes.filter((propagationNode) => {
|
||||
@@ -167,6 +213,79 @@ export default {
|
||||
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>
|
||||
|
||||
@@ -1,148 +1,212 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto space-y-2 p-2">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900">
|
||||
<div class="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 -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Appearance</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex">
|
||||
<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">
|
||||
<option value="light">Light Theme</option>
|
||||
<option value="dark">Dark Theme</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- hero card -->
|
||||
<div class="bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl p-5 md:p-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-4">
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Profile</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ config.display_name }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Manage your identity, transport participation and LXMF defaults.</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<button @click="copyValue(config.identity_hash, 'Identity Hash')" type="button" class="inline-flex items-center justify-center gap-x-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-zinc-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-400/70 transition">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||
Identity
|
||||
</button>
|
||||
<button @click="copyValue(config.lxmf_address_hash, 'LXMF Address')" type="button" class="inline-flex items-center justify-center gap-x-2 rounded-xl bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 px-4 py-2 text-sm font-semibold text-white shadow hover:shadow-md transition">
|
||||
<MaterialDesignIcon icon-name="account-plus" class="w-4 h-4"/>
|
||||
LXMF Address
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="copyToast" class="mt-3 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200 px-3 py-1 text-xs inline-flex items-center gap-2">
|
||||
{{ copyToast }}
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-ping"></span>
|
||||
</div>
|
||||
</transition>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70">
|
||||
<div class="text-xs uppercase tracking-wide">Theme</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white capitalize">{{ config.theme }} mode</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70">
|
||||
<div class="text-xs uppercase tracking-wide">Transport</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white">{{ config.is_transport_enabled ? 'Enabled' : 'Disabled' }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70">
|
||||
<div class="text-xs uppercase tracking-wide">Propagation</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white">{{ config.lxmf_local_propagation_node_enabled ? 'Local node running' : 'Client-only' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 mt-4 text-sm text-gray-700 dark:text-gray-200 sm:grid-cols-2">
|
||||
<div class="address-card">
|
||||
<div class="address-card__label">Identity Hash</div>
|
||||
<div class="address-card__value monospace-field">{{ config.identity_hash }}</div>
|
||||
<button @click="copyValue(config.identity_hash, 'Identity Hash')" type="button" class="address-card__action">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="address-card">
|
||||
<div class="address-card__label">LXMF Address</div>
|
||||
<div class="address-card__value monospace-field">{{ config.lxmf_address_hash }}</div>
|
||||
<button @click="copyValue(config.lxmf_address_hash, 'LXMF Address')" type="button" class="address-card__action">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- transport mode -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Transport Mode</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
<!-- settings grid -->
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input v-model="config.is_transport_enabled" @change="onIsTransportEnabledChange" 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">Enable Transport Mode</label>
|
||||
<!-- Appearance -->
|
||||
<section class="glass-card">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Personalise</div>
|
||||
<h2>Appearance</h2>
|
||||
<p>Switch between light and dark presets anytime.</p>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, MeshChat will route traffic for other peers, respond to path requests and pass announces over your interfaces.</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">Changing this setting requires you to restart MeshChat.</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- interfaces -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Interfaces</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<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>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Show Community Interfaces</label>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-3">
|
||||
<select v-model="config.theme" @change="onThemeChange" class="input-field">
|
||||
<option value="light">Light Theme</option>
|
||||
<option value="dark">Dark Theme</option>
|
||||
</select>
|
||||
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-300 border border-dashed border-gray-200 dark:border-zinc-800 rounded-2xl px-3 py-2">
|
||||
<div>Live preview updates instantly.</div>
|
||||
<span class="inline-flex items-center gap-1 text-blue-500 dark:text-blue-300 text-xs font-semibold uppercase">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
|
||||
Realtime
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, community interfaces will be shown on the Add Interface page.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- messages -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Messages</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<div class="p-2">
|
||||
<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>
|
||||
<!-- Transport -->
|
||||
<section class="glass-card">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Reticulum</div>
|
||||
<h2>Transport Mode</h2>
|
||||
<p>Relay paths and traffic for nearby peers.</p>
|
||||
</div>
|
||||
<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>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-3">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.is_transport_enabled" @change="onIsTransportEnabledChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Enable Transport Mode</span>
|
||||
<span class="setting-toggle__description">Route announces, respond to path requests and help your mesh stay online.</span>
|
||||
<span class="setting-toggle__hint">Requires restart after toggling.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<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>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Allow resending with attachments</label>
|
||||
<!-- Interfaces -->
|
||||
<section class="glass-card">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Adapters</div>
|
||||
<h2>Interfaces</h2>
|
||||
<p>Show curated community configs inside the interface wizard.</p>
|
||||
</div>
|
||||
<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.show_suggested_community_interfaces" @change="onShowSuggestedCommunityInterfacesChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Show Community Interfaces</span>
|
||||
<span class="setting-toggle__description">Surface community-maintained presets while adding new interfaces.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<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>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Auto send to propagation node</label>
|
||||
<!-- Messages -->
|
||||
<section class="glass-card">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Reliability</div>
|
||||
<h2>Messages</h2>
|
||||
<p>Control how MeshChat retries or escalates failed deliveries.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-3">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.auto_resend_failed_messages_when_announce_received" @change="onAutoResendFailedMessagesWhenAnnounceReceivedChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Auto resend when peer announces</span>
|
||||
<span class="setting-toggle__description">Failed messages automatically retry once the destination broadcasts again.</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.allow_auto_resending_failed_messages_with_attachments" @change="onAllowAutoResendingFailedMessagesWithAttachmentsChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Allow retries with attachments</span>
|
||||
<span class="setting-toggle__description">Large payloads will also be retried (useful when both peers have high limits).</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.auto_send_failed_messages_to_propagation_node" @change="onAutoSendFailedMessagesToPropagationNodeChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Auto fall back to propagation node</span>
|
||||
<span class="setting-toggle__description">Failed direct deliveries are queued on your preferred propagation node.</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Inbound Message Stamp Cost</div>
|
||||
<input v-model.number="config.lxmf_inbound_stamp_cost" @input="onLxmfInboundStampCostChange" type="number" min="1" max="254" placeholder="8" class="input-field">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Require proof-of-work stamps for direct delivery messages sent to you. Higher values require more computational work from senders. Range: 1-254. Default: 8.
|
||||
</div>
|
||||
</div>
|
||||
<div 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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- propagation nodes -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">
|
||||
<div class="my-auto mr-auto">Propagation Nodes</div>
|
||||
<div class="my-auto">
|
||||
<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">
|
||||
<!-- Propagation nodes -->
|
||||
<section class="glass-card lg:col-span-2">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">LXMF</div>
|
||||
<h2>Propagation Nodes</h2>
|
||||
<p>Keep conversations flowing even when peers are offline.</p>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'propagation-nodes' }" class="primary-chip">
|
||||
Browse Nodes
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<ul class="list-disc list-inside">
|
||||
<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>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-5">
|
||||
<div class="info-callout">
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>Propagation nodes hold messages securely until recipients sync again.</li>
|
||||
<li>Nodes peer with each other to distribute encrypted payloads.</li>
|
||||
<li>Most nodes retain data ~30 days, then discard undelivered items.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<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">
|
||||
</div>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Local Propagation Node</label>
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.lxmf_local_propagation_node_enabled" @change="onLxmfLocalPropagationNodeEnabledChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Run a local propagation node</span>
|
||||
<span class="setting-toggle__description">MeshChat will announce and maintain a node using this local destination hash.</span>
|
||||
<span class="setting-toggle__hint monospace-field">{{ config.lxmf_local_propagation_node_address_hash || '—' }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Preferred Propagation Node</div>
|
||||
<input v-model="config.lxmf_preferred_propagation_node_destination_hash" @input="onLxmfPreferredPropagationNodeDestinationHashChange" type="text" placeholder="Destination hash, e.g. a39610c89d18bb48c73e429582423c24" class="input-field monospace-field">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Messages fallback to this node whenever direct delivery fails.</div>
|
||||
</div>
|
||||
<div class="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="flex">
|
||||
<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">
|
||||
</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">
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Auto Sync Interval</div>
|
||||
<select v-model="config.lxmf_preferred_propagation_node_auto_sync_interval_seconds" @change="onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange" class="input-field">
|
||||
<option value="0">Disabled</option>
|
||||
<option value="900">Every 15 Minutes</option>
|
||||
<option value="1800">Every 30 Minutes</option>
|
||||
@@ -152,16 +216,23 @@
|
||||
<option value="43200">Every 12 Hours</option>
|
||||
<option value="86400">Every 24 Hours</option>
|
||||
</select>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span v-if="config.lxmf_preferred_propagation_node_last_synced_at">Last synced {{ formatSecondsAgo(config.lxmf_preferred_propagation_node_last_synced_at) }} ago.</span>
|
||||
<span v-else>Last synced: never.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<span v-if="config.lxmf_preferred_propagation_node_last_synced_at">Last Synced: {{ formatSecondsAgo(config.lxmf_preferred_propagation_node_last_synced_at) }}</span>
|
||||
<span v-else>Last Synced: Never</span>
|
||||
<div v-if="config.lxmf_local_propagation_node_enabled" class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Propagation Node Stamp Cost</div>
|
||||
<input v-model.number="config.lxmf_propagation_node_stamp_cost" @input="onLxmfPropagationNodeStampCostChange" type="number" min="13" max="254" placeholder="16" class="input-field">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Require proof-of-work stamps for messages propagated through your node. Higher values require more computational work. Range: 13-254. Default: 16. <strong>Note:</strong> Changing this requires restarting the app.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -170,9 +241,13 @@
|
||||
import Utils from "../../js/Utils";
|
||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: 'SettingsPage',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config: {
|
||||
@@ -183,12 +258,15 @@ export default {
|
||||
lxmf_local_propagation_node_enabled: null,
|
||||
lxmf_preferred_propagation_node_destination_hash: null,
|
||||
},
|
||||
copyToast: null,
|
||||
copyToastTimeout: null,
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
clearTimeout(this.copyToastTimeout);
|
||||
|
||||
},
|
||||
mounted() {
|
||||
@@ -227,6 +305,25 @@ export default {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async copyValue(value, label) {
|
||||
if(!value){
|
||||
DialogUtils.alert(`Nothing to copy for ${label}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
this.showCopyToast(`${label} copied to clipboard`);
|
||||
} catch(e) {
|
||||
DialogUtils.alert(`${label}: ${value}`);
|
||||
}
|
||||
},
|
||||
showCopyToast(message) {
|
||||
this.copyToast = message;
|
||||
clearTimeout(this.copyToastTimeout);
|
||||
this.copyToastTimeout = setTimeout(() => {
|
||||
this.copyToast = null;
|
||||
}, 2500);
|
||||
},
|
||||
async onThemeChange() {
|
||||
await this.updateConfig({
|
||||
"theme": this.config.theme,
|
||||
@@ -267,6 +364,16 @@ export default {
|
||||
"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 {
|
||||
@@ -292,3 +399,74 @@ export default {
|
||||
},
|
||||
}
|
||||
</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>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto space-y-2 p-2">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900">
|
||||
<div class="overflow-y-auto space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
|
||||
|
||||
<!-- appearance -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Tools</div>
|
||||
<div class="dark:divide-zinc-700 text-gray-900 dark:text-gray-100 p-2">
|
||||
A collection of useful tools bundled with MeshChat
|
||||
<div class="glass-card space-y-3">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Utilities</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">Power tools for operators</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Diagnostics and firmware helpers ship with MeshChat so you can troubleshoot peers without leaving the console.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ping -->
|
||||
<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">
|
||||
<div class="mr-2">
|
||||
<div class="flex bg-gray-300 text-gray-500 rounded shadow p-2">
|
||||
<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 class="grid gap-4 md:grid-cols-2">
|
||||
<RouterLink :to="{ name: 'ping' }" class="tool-card glass-card">
|
||||
<div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
|
||||
<MaterialDesignIcon icon-name="radar" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto mr-auto dark:text-gray-200">
|
||||
<div class="font-bold">Ping</div>
|
||||
<div class="text-sm">Allows you to ping a destination hash.</div>
|
||||
</div>
|
||||
<div class="my-auto text-gray-400 group-hover: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="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>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">Ping</div>
|
||||
<div class="tool-card__description">Latency test for any LXMF destination hash with live status.</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron"/>
|
||||
</RouterLink>
|
||||
|
||||
<!-- rnode flasher -->
|
||||
<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="mr-2">
|
||||
<div class="flex bg-gray-300 text-white rounded shadow">
|
||||
<img src="/rnode-flasher/reticulum_logo_512.png" class="size-14"/>
|
||||
<a target="_blank" href="/rnode-flasher/index.html" class="tool-card glass-card">
|
||||
<div class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200">
|
||||
<img src="/rnode-flasher/reticulum_logo_512.png" class="w-8 h-8 rounded-full" alt="RNode"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto mr-auto dark:text-gray-200">
|
||||
<div class="font-bold">RNode Flasher</div>
|
||||
<div class="text-sm">Flash RNode firmware to supported devices.</div>
|
||||
</div>
|
||||
<div class="my-auto text-gray-400 group-hover: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="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>
|
||||
</a>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">RNode Flasher</div>
|
||||
<div class="tool-card__description">Flash and update RNode adapters without touching the command line.</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="open-in-new" class="tool-card__chevron"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: 'ToolsPage',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tool-card {
|
||||
@apply flex items-center gap-4 hover:border-blue-400 dark:hover:border-blue-500 transition cursor-pointer;
|
||||
}
|
||||
.tool-card__icon {
|
||||
@apply w-12 h-12 rounded-2xl flex items-center justify-center;
|
||||
}
|
||||
.tool-card__title {
|
||||
@apply text-lg font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
.tool-card__description {
|
||||
@apply text-sm text-gray-600 dark:text-gray-300;
|
||||
}
|
||||
.tool-card__chevron {
|
||||
@apply w-5 h-5 text-gray-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,14 +9,6 @@
|
||||
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
|
||||
<title>Reticulum MeshChat</title>
|
||||
|
||||
<!-- codec2 -->
|
||||
<script src="assets/js/codec2-emscripten/c2enc.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/c2dec.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/sox.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/codec2-lib.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/wav-encoder.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/codec2-microphone-recorder.js"></script>
|
||||
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div id="app"></div>
|
||||
|
||||
62
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;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,13 @@ class Utils {
|
||||
|
||||
}
|
||||
|
||||
static formatNumber(num) {
|
||||
if(num === 0){
|
||||
return '0';
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
static parseSeconds(secondsToFormat) {
|
||||
secondsToFormat = Number(secondsToFormat);
|
||||
var days = Math.floor(secondsToFormat / (3600 * 24));
|
||||
@@ -127,6 +134,22 @@ class Utils {
|
||||
|
||||
}
|
||||
|
||||
static formatBytesPerSecond(bytesPerSecond) {
|
||||
|
||||
if(bytesPerSecond === 0 || bytesPerSecond == null){
|
||||
return '0 B/s';
|
||||
}
|
||||
|
||||
const k = 1024;
|
||||
const decimals = 1;
|
||||
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
|
||||
|
||||
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));
|
||||
|
||||
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
|
||||
|
||||
}
|
||||
|
||||
static formatFrequency(hz) {
|
||||
|
||||
if(hz === 0 || hz == null){
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
import vClickOutside from "click-outside-vue3";
|
||||
import "./style.css";
|
||||
import "./fonts/RobotoMonoNerdFont/font.css";
|
||||
import { ensureCodec2ScriptsLoaded } from "./js/Codec2Loader";
|
||||
|
||||
import App from './components/App.vue';
|
||||
|
||||
@@ -50,6 +51,13 @@ const router = createRouter({
|
||||
props: true,
|
||||
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "messages-popout",
|
||||
path: '/popout/messages/:destinationHash?',
|
||||
props: true,
|
||||
meta: { popoutType: "conversation", isPopout: true },
|
||||
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "network-visualiser",
|
||||
path: '/network-visualiser',
|
||||
@@ -61,6 +69,13 @@ const router = createRouter({
|
||||
props: true,
|
||||
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "nomadnetwork-popout",
|
||||
path: '/popout/nomadnetwork/:destinationHash?',
|
||||
props: true,
|
||||
meta: { popoutType: "nomad", isPopout: true },
|
||||
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "propagation-nodes",
|
||||
path: '/propagation-nodes',
|
||||
@@ -86,11 +101,21 @@ const router = createRouter({
|
||||
path: '/tools',
|
||||
component: defineAsyncComponent(() => import("./components/tools/ToolsPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "call",
|
||||
path: '/call',
|
||||
component: defineAsyncComponent(() => import("./components/call/CallPage.vue")),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(vuetify)
|
||||
.use(vClickOutside)
|
||||
.mount('#app');
|
||||
async function bootstrap() {
|
||||
await ensureCodec2ScriptsLoaded();
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(vuetify)
|
||||
.use(vClickOutside)
|
||||
.mount('#app');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,81 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@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"),
|
||||
|
||||
},
|
||||
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