Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1f9632b396
|
|||
|
6ecd46dcec
|
|||
|
0b5c8e4e68
|
|||
|
42e7c2cf3b
|
|||
|
e23c5abdd9
|
|||
|
fea9389a14
|
|||
|
20c0e10767
|
|||
|
7618300619
|
|||
|
98092d3c77
|
|||
|
2d8e050d61
|
|||
|
dc6f0cae29
|
|||
|
2f00c39aba
|
|||
|
92204ba16a
|
|||
|
821add9d46
|
|||
|
174a38eb91
|
|||
|
227ed0fd44
|
|||
| 0dbe463eb5 | |||
| 31bb72448e | |||
| a10ffd3102 | |||
|
9205006949
|
|||
| b15e9bb7e1 | |||
|
72e52b989d
|
|||
|
e179ee734c
|
|||
|
331c00fe70
|
|||
|
8c0f4573fd
|
|||
|
3be1c30ff6
|
|||
|
b18a538ced
|
|||
|
705b7e88f7
|
|||
|
a465408798
|
|||
|
6ef13ded78
|
|||
|
7746a2db98
|
|||
|
ba61fab06a
|
|||
|
87a56d08b8
|
|||
|
2b86ea98df
|
|||
|
316aa4e556
|
|||
|
29b12ac940
|
|||
|
4221f13ba1
|
|||
|
18bcce4f4b
|
|||
|
bce2237a1b
|
|||
|
223dac4708
|
|||
|
54700c0dee
|
|||
|
d8af1836cd
|
|||
|
113ececd7a
|
|||
|
05961f160a
|
|||
|
825fefdeb1
|
|||
|
2db5c88c8d
|
|||
|
1d52056a2d
|
|||
|
5ca7308d66
|
|||
|
5ad8003d81
|
|||
|
d0d204a7d3
|
|||
|
0bc9deffed
|
|||
|
1097a1e5e7
|
|||
|
eafa345ce7
|
|||
|
e59220e5f9
|
|||
|
d579a201b3
|
|||
|
3e09d7bc44
|
|||
|
802a1a6217
|
|||
|
5b56f449ef
|
|||
|
ba2ba39524
|
|||
|
93839f0476
|
|||
|
2840ddfa3e
|
|||
|
e848306d6d
|
|||
|
6dda5b8c26
|
|||
|
1f1697e53f
|
|||
|
b73c96b069
|
|||
|
c5deab0767
|
|||
|
9d8af704b8
|
|||
|
c10fef9723
|
|||
|
62926140df
|
|||
|
fc1e5cc9b6
|
|||
|
d2c2d7a02b
|
|||
|
7555d95d32
|
|||
|
78572a6090
|
@@ -11,6 +11,8 @@ electron/
|
||||
android/
|
||||
scripts/
|
||||
Makefile
|
||||
*.apk
|
||||
*.aab
|
||||
|
||||
# Build artifacts and cache
|
||||
build/
|
||||
|
||||
118
.gitea/workflows/android-build.yml
Normal file
@@ -0,0 +1,118 @@
|
||||
name: Build Android APK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g., v1.0.0)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
name: Build Android APK
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone Repo
|
||||
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.version }}" ]; then
|
||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
||||
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: https://git.quad4.io/actions/setup-java@f905b4359421f885fd1d195484604c02d27cefed # v5.1.0
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "17"
|
||||
|
||||
- name: Install NodeJS
|
||||
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install Python
|
||||
uses: https://git.quad4.io/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install Poetry
|
||||
run: python -m pip install --upgrade pip poetry>=2.0.0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: https://git.quad4.io/actions/setup-pnpm@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
with:
|
||||
version: 10.0.0
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y patchelf libopusfile0 ffmpeg espeak-ng cmake ninja-build clang pkg-config
|
||||
|
||||
- name: Setup Task
|
||||
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
|
||||
with:
|
||||
version: "3.46.3"
|
||||
|
||||
- name: Sync versions
|
||||
run: python scripts/sync_version.py
|
||||
|
||||
- name: Install dependencies
|
||||
run: task install
|
||||
|
||||
- name: Build Frontend and Prepare Android
|
||||
run: task android-prepare
|
||||
|
||||
- name: Build Android APK
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
env:
|
||||
JAVA_HOME: ${{ env.JAVA_HOME_17_X64 }}
|
||||
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
# Collect APK
|
||||
find android/app/build/outputs/apk/debug -name "*.apk" -exec cp {} release-assets/MeshChatX-${{ steps.version.outputs.version }}-debug.apk \;
|
||||
|
||||
# Generate checksums
|
||||
cd release-assets
|
||||
for file in *; do
|
||||
if [ -f "$file" ]; then
|
||||
sha256sum "$file" | tee "${file}.sha256"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: https://git.quad4.io/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3.2.1
|
||||
with:
|
||||
name: meshchatx-android-apk
|
||||
path: release-assets/*
|
||||
|
||||
- name: Create Release
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: https://git.quad4.io/actions/gitea-release-action@4875285c0950474efb7ca2df55233c51333eeb74 # v1
|
||||
with:
|
||||
api_url: ${{ secrets.GITEA_API_URL }}
|
||||
gitea_token: ${{ secrets.GITEA_TOKEN }}
|
||||
title: Android Build ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.version }}-android
|
||||
files: "release-assets/*"
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -69,46 +69,56 @@ jobs:
|
||||
version: 10.0.0
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y patchelf libopusfile0 ffmpeg espeak-ng
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y patchelf libopusfile0 ffmpeg espeak-ng wine nsis
|
||||
|
||||
- name: Setup Task
|
||||
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
|
||||
with:
|
||||
version: "3.46.3"
|
||||
|
||||
- name: Sync versions
|
||||
run: python scripts/sync_version.py
|
||||
|
||||
- name: Install dependencies
|
||||
run: task install
|
||||
|
||||
- name: Build Frontend
|
||||
run: task build-frontend
|
||||
|
||||
- name: Prepare frontend directory
|
||||
run: python scripts/prepare_frontend_dir.py
|
||||
|
||||
- name: Build Python wheel
|
||||
run: task wheel
|
||||
|
||||
- name: Build Electron App (Universal)
|
||||
run: pnpm run dist-prebuilt
|
||||
- name: Build Electron App (Linux)
|
||||
run: task build-electron-linux
|
||||
|
||||
- name: Build Electron App (Windows)
|
||||
run: task build-electron-windows
|
||||
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
# Collect artifacts
|
||||
find dist -type f \( -name "*-linux*.AppImage" -o -name "*-linux*.deb" \) -exec cp {} release-assets/ \;
|
||||
find dist -type f \( -name "*-win*.exe" -o -name "*-win-portable*.exe" \) -exec cp {} release-assets/ \;
|
||||
find python-dist -type f -name "*.whl" -exec cp {} release-assets/ \;
|
||||
|
||||
# Generate checksums
|
||||
cd release-assets
|
||||
for file in *; do
|
||||
if [ -f "$file" ] && [[ "$file" != *.sha256 ]]; then
|
||||
sha256sum "$file" | tee "${file}.sha256"
|
||||
fi
|
||||
done
|
||||
|
||||
# Generate release notes (outside release-assets directory)
|
||||
cd ..
|
||||
echo "## SHA256 Checksums" > release-body.md
|
||||
echo "" >> release-body.md
|
||||
for file in *; do
|
||||
if [ -f "$file" ] && [ "$file" != "release-body.md" ]; then
|
||||
sha256sum "$file" | tee "${file}.sha256"
|
||||
echo "\`$(cat "${file}.sha256")\`" >> release-body.md
|
||||
for file in release-assets/*; do
|
||||
if [ -f "$file" ] && [[ "$file" != *.sha256 ]] && [[ "$file" != *release-body.md* ]]; then
|
||||
filename=$(basename "$file")
|
||||
if [ -f "release-assets/${filename}.sha256" ]; then
|
||||
echo "\`$(cat "release-assets/${filename}.sha256")\`" >> release-body.md
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -133,47 +143,52 @@ jobs:
|
||||
gitea_token: ${{ secrets.GITEA_TOKEN }}
|
||||
title: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.version }}
|
||||
files: "release-assets/*"
|
||||
bodyFile: "release-assets/release-body.md"
|
||||
files: |
|
||||
release-assets/*.AppImage
|
||||
release-assets/*.deb
|
||||
release-assets/*.exe
|
||||
release-assets/*.whl
|
||||
release-assets/*.sha256
|
||||
body_path: "release-body.md"
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
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: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
# 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: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set lowercase repository owner
|
||||
run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
|
||||
# - name: Set lowercase repository owner
|
||||
# run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
# - name: Set up QEMU
|
||||
# uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://git.quad4.io/actions/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: https://git.quad4.io/actions/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: https://git.quad4.io/actions/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# - name: Log in to the GitHub Container registry
|
||||
# uses: https://git.quad4.io/actions/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.actor }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: https://git.quad4.io/actions/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: >-
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:latest,
|
||||
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:${{ github.ref_name }}
|
||||
labels: >-
|
||||
org.opencontainers.image.title=Reticulum MeshChatX,
|
||||
org.opencontainers.image.description=Docker image for Reticulum MeshChatX,
|
||||
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchatx/
|
||||
# - name: Build and push Docker images
|
||||
# uses: https://git.quad4.io/actions/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
# with:
|
||||
# context: .
|
||||
# platforms: linux/amd64,linux/arm64
|
||||
# push: true
|
||||
# tags: >-
|
||||
# ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:latest,
|
||||
# ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:${{ github.ref_name }}
|
||||
# labels: >-
|
||||
# org.opencontainers.image.title=Reticulum MeshChatX,
|
||||
# org.opencontainers.image.description=Docker image for Reticulum MeshChatX,
|
||||
# org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchatx/
|
||||
|
||||
66
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
UV_LINK_MODE: copy
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup Node.js
|
||||
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- name: Setup Python
|
||||
uses: https://git.quad4.io/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Setup Task
|
||||
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
|
||||
with:
|
||||
version: "3.46.3"
|
||||
- name: Setup uv
|
||||
run: |
|
||||
task setup-uv
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Setup Python environment
|
||||
run: task setup-python-env
|
||||
- name: Install Node dependencies
|
||||
run: task node_modules
|
||||
- name: Lint
|
||||
run: task lint
|
||||
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup Node.js
|
||||
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- name: Setup Task
|
||||
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
|
||||
with:
|
||||
version: "3.46.3"
|
||||
- name: Install dependencies
|
||||
run: task node_modules
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT
|
||||
- name: Build frontend
|
||||
run: task build-frontend
|
||||
env:
|
||||
VITE_APP_VERSION: ${{ steps.version.outputs.version }}
|
||||
5
.gitignore
vendored
@@ -40,9 +40,14 @@ android/local.properties
|
||||
android/build/
|
||||
android/app/build/
|
||||
android/app/src/main/python/meshchatx/
|
||||
android/app/src/main/python/RNS/
|
||||
android/app/src/main/python/LXMF/
|
||||
android/app/src/main/python/LXST/
|
||||
android/app/src/main/python/meshchat_wrapper.py.bak
|
||||
android/*.iml
|
||||
android/.idea/
|
||||
*.apk
|
||||
*.aab
|
||||
|
||||
|
||||
# Local storage and runtime data
|
||||
|
||||
100
README.md
@@ -1,34 +1,103 @@
|
||||
# Reticulum MeshChatX
|
||||
|
||||
A heavily customized and updated fork of [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat).
|
||||
A [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat) fork from the future.
|
||||
|
||||
This project is seperate from the original Reticulum MeshChat project, and is not affiliated with the original project. It has been completely reworked, but I try to maintain a migrator to auto-migrate from old database to new one.
|
||||
<video src="showcase/showcase-video-call.mp4" controls="controls" style="max-width: 100%;"></video>
|
||||
|
||||
This project is seperate from the original Reticulum MeshChat project, and is not affiliated with the original project.
|
||||
|
||||
> [!WARNING]
|
||||
> Backup your reticulum-meshchat folder before using, even though MeshChatX will attempt to auto-migrate whatever it can from the old database without breaking things. Its best to keep backups.
|
||||
|
||||
## Major Features
|
||||
|
||||
- Full LXST support w/ custom voicemail support (espeak-ng and ffmpeg required).
|
||||
- Map (w/ MBTiles support for offline)
|
||||
- Security improvements
|
||||
- Custom UI/UX
|
||||
- More Tools
|
||||
- Built-in page archiving and automatic crawler (no multi-page support yet).
|
||||
- Block LXMF users and NomadNet Nodes
|
||||
- Full LXST support w/ custom voicemail, phonebook, contacts, contact sharing and ringtone support.
|
||||
- Multi-identity support.
|
||||
- Authentication
|
||||
- Map (OpenLayers w/ MBTiles upload and exporter for offline maps)
|
||||
- Security improvements (automatic HTTPS, CORS, and much more)
|
||||
- Modern Custom UI/UX
|
||||
- More Tools (RNStatus, RNProbe, RNCP and Translator)
|
||||
- Built-in page archiving and automatic crawler.
|
||||
- Block LXMF users, Telephony and NomadNet Nodes
|
||||
- Toast system for notifications
|
||||
- i18n support (En, De, Ru)
|
||||
- Raw SQLite database backend (replaced Peewee ORM)
|
||||
- LXMF Telemetry support (WIP)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<details>
|
||||
<summary>Telephony & Calling</summary>
|
||||
|
||||
### Phone
|
||||
|
||||

|
||||
|
||||
### Active Call
|
||||
|
||||

|
||||
|
||||
### Call Ended
|
||||
|
||||

|
||||
|
||||
### Voicemail
|
||||
|
||||

|
||||
|
||||
### Ringtone Settings
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Networking & Visualization</summary>
|
||||
|
||||
### Network Visualiser
|
||||
|
||||

|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Page Archives</summary>
|
||||
|
||||
### Archives Browser
|
||||
|
||||

|
||||
|
||||
### Viewing Archived Page
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Tools & Identities</summary>
|
||||
|
||||
### Tools
|
||||
|
||||

|
||||
|
||||
### Identity Management
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Tests and proper CI/CD pipeline.
|
||||
- [ ] RNS hot reload fix
|
||||
- [ ] Backup/Import identities, messages and interfaces.
|
||||
- [ ] Offline Reticulum documentation tool
|
||||
- [ ] LXMF Telemtry for map
|
||||
- [ ] Spam filter (based on keywords)
|
||||
- [ ] Multi-identity support.
|
||||
- [ ] TAK tool/integration
|
||||
- [ ] RNS Tunnel - tunnel your regular services over RNS to another MeshchatX user.
|
||||
- [ ] RNS Filesync - P2P file sync
|
||||
- [ ] RNS Page Node
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -75,7 +144,7 @@ All tasks support environment variable overrides. For example:
|
||||
|
||||
### Python Packaging
|
||||
|
||||
The backend uses Poetry with `pyproject.toml` for dependency management and packaging. Before building, run `python3 scripts/sync_version.py` (or `task sync-version`) to ensure the generated `src/version.py` reflects the version from `package.json` that the Electron artifacts use. This keeps the CLI release metadata, wheel packages, and other bundles aligned.
|
||||
The backend uses Poetry with `pyproject.toml` for dependency management and packaging. `package.json` is the source of truth for the project version. When you run `task install` (or any task that builds/runs the app), the version is automatically synced to `pyproject.toml` and `src/version.py`. This keeps the CLI release metadata, wheel packages, and other bundles aligned with the Electron build.
|
||||
|
||||
#### Build Artifact Locations
|
||||
|
||||
@@ -140,3 +209,8 @@ Currently supported languages:
|
||||
- English (Primary)
|
||||
- Russian
|
||||
- German
|
||||
|
||||
## Credits
|
||||
|
||||
- [Liam Cottle](https://github.com/liamcottle) - Original Reticulum MeshChat
|
||||
- [micron-parser-js](https://github.com/RFnexus/micron-parser-js) by [RFnexus](https://github.com/RFnexus)
|
||||
|
||||
174
Taskfile.yml
@@ -25,6 +25,24 @@ vars:
|
||||
sh: echo "${DOCKER_CONTEXT:-.}"
|
||||
DOCKERFILE:
|
||||
sh: echo "${DOCKERFILE:-Dockerfile}"
|
||||
ANDROID_DIR:
|
||||
sh: echo "${ANDROID_DIR:-android}"
|
||||
PYTHON_SRC_DIR:
|
||||
sh: echo "${PYTHON_SRC_DIR:-${ANDROID_DIR}/app/src/main/python}"
|
||||
JNI_LIBS_DIR:
|
||||
sh: echo "${JNI_LIBS_DIR:-${ANDROID_DIR}/app/src/main/jniLibs}"
|
||||
RETICULUM_RNS_SRC:
|
||||
sh: echo "${RETICULUM_RNS_SRC:-./misc/RNS}"
|
||||
RETICULUM_LXMF_SRC:
|
||||
sh: echo "${RETICULUM_LXMF_SRC:-./misc/LXMF}"
|
||||
RETICULUM_LXST_SRC:
|
||||
sh: echo "${RETICULUM_LXST_SRC:-./misc/LXST}"
|
||||
SIDEBAND_CODEC2_WHL:
|
||||
sh: echo "${SIDEBAND_CODEC2_WHL:-./misc/pycodec2-3.0.1-cp311-cp311-linux_aarch64.whl}"
|
||||
SIDEBAND_LIB_ARM64:
|
||||
sh: echo "${SIDEBAND_LIB_ARM64:-./misc/libcodec2-arm64-v8a.so}"
|
||||
SIDEBAND_LIB_ARMEABI:
|
||||
sh: echo "${SIDEBAND_LIB_ARMEABI:-./misc/libcodec2-armeabi-v7a.so}"
|
||||
|
||||
tasks:
|
||||
default:
|
||||
@@ -32,9 +50,35 @@ tasks:
|
||||
cmds:
|
||||
- task --list
|
||||
|
||||
setup-uv:
|
||||
desc: Install uv
|
||||
cmds:
|
||||
- curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
setup-python-env:
|
||||
desc: Setup Python environment using uv
|
||||
cmds:
|
||||
- uv venv
|
||||
- uv pip install ruff poetry
|
||||
|
||||
lint-python:
|
||||
desc: Lint Python code using ruff
|
||||
cmds:
|
||||
- uv run ruff check .
|
||||
- uv run ruff format --check .
|
||||
|
||||
lint-frontend:
|
||||
desc: Lint frontend code
|
||||
cmds:
|
||||
- "{{.NPM}} run lint"
|
||||
|
||||
lint:
|
||||
desc: Run all linters
|
||||
deps: [lint-frontend, lint-python]
|
||||
|
||||
install:
|
||||
desc: Install all dependencies (syncs version, installs node modules and python deps)
|
||||
deps: [sync-version, node_modules, python]
|
||||
desc: Install all dependencies (installs node modules and python deps)
|
||||
deps: [node_modules, python]
|
||||
|
||||
node_modules:
|
||||
desc: Install Node.js dependencies
|
||||
@@ -52,8 +96,9 @@ tasks:
|
||||
cmds:
|
||||
- "{{.PYTHON}} -m poetry run meshchat"
|
||||
|
||||
develop:
|
||||
dev:
|
||||
desc: Run the application in development mode
|
||||
deps: [build-frontend]
|
||||
cmds:
|
||||
- task: run
|
||||
|
||||
@@ -90,6 +135,24 @@ tasks:
|
||||
- "{{.NPM}} run electron-postinstall"
|
||||
- "{{.NPM}} run dist -- --win portable"
|
||||
|
||||
build-electron-linux:
|
||||
desc: Build Linux Electron app with prebuilt backend
|
||||
deps: [build-frontend]
|
||||
cmds:
|
||||
- "{{.NPM}} run electron-postinstall"
|
||||
- "{{.NPM}} run build-backend"
|
||||
- "{{.NPM}} run dist -- --linux AppImage"
|
||||
- "{{.NPM}} run dist -- --linux deb"
|
||||
|
||||
build-electron-windows:
|
||||
desc: Build Windows Electron apps (portable and installer)
|
||||
deps: [build-frontend]
|
||||
cmds:
|
||||
- "{{.NPM}} run electron-postinstall"
|
||||
- "{{.NPM}} run build-backend"
|
||||
- "{{.NPM}} run dist -- --win portable"
|
||||
- "{{.NPM}} run dist -- --win nsis"
|
||||
|
||||
dist:
|
||||
desc: Build distribution (defaults to AppImage)
|
||||
cmds:
|
||||
@@ -124,12 +187,16 @@ tasks:
|
||||
- rm -rf dist
|
||||
- rm -rf python-dist
|
||||
- rm -rf meshchatx/public
|
||||
- rm -rf build-dir
|
||||
- task: android-clean
|
||||
|
||||
sync-version:
|
||||
desc: Sync version numbers across project files
|
||||
fix:
|
||||
desc: Format and fix linting issues (Python and frontend)
|
||||
cmds:
|
||||
- "{{.PYTHON}} scripts/sync_version.py"
|
||||
- uv run ruff format ./
|
||||
- uv run ruff check --fix ./
|
||||
- "{{.NPM}} run format"
|
||||
- "{{.NPM}} run lint:fix"
|
||||
|
||||
build-docker:
|
||||
desc: Build Docker image using buildx
|
||||
@@ -170,43 +237,110 @@ tasks:
|
||||
desc: Initialize Gradle wrapper for Android project
|
||||
cmds:
|
||||
- |
|
||||
if [ ! -f android/gradle/wrapper/gradle-wrapper.jar ]; then
|
||||
if [ ! -f "{{.ANDROID_DIR}}/gradle/wrapper/gradle-wrapper.jar" ]; then
|
||||
echo "Downloading Gradle wrapper jar..."
|
||||
mkdir -p android/gradle/wrapper
|
||||
curl -L -o android/gradle/wrapper/gradle-wrapper.jar \
|
||||
mkdir -p "{{.ANDROID_DIR}}/gradle/wrapper"
|
||||
curl -L -o "{{.ANDROID_DIR}}/gradle/wrapper/gradle-wrapper.jar" \
|
||||
https://raw.githubusercontent.com/gradle/gradle/v8.12.1/gradle/wrapper/gradle-wrapper.jar || \
|
||||
echo "Failed to download. Please run: cd android && gradle wrapper --gradle-version 8.12.1"
|
||||
echo "Failed to download. Please run: cd {{.ANDROID_DIR}} && gradle wrapper --gradle-version 8.12.1"
|
||||
else
|
||||
echo "Gradle wrapper already initialized."
|
||||
fi
|
||||
|
||||
android-prepare:
|
||||
desc: Prepare Android build (copy meshchatx package and assets)
|
||||
deps: [build, android-init]
|
||||
deps: [build-frontend, android-init]
|
||||
cmds:
|
||||
- |
|
||||
echo "Copying meshchatx package to Android project..."
|
||||
mkdir -p android/app/src/main/python
|
||||
cp -r meshchatx android/app/src/main/python/
|
||||
echo "Copying meshchatx package and dependencies to Android project..."
|
||||
mkdir -p "{{.PYTHON_SRC_DIR}}"
|
||||
# Remove old copies to ensure fresh build
|
||||
rm -rf "{{.PYTHON_SRC_DIR}}/meshchatx"
|
||||
rm -rf "{{.PYTHON_SRC_DIR}}/RNS"
|
||||
rm -rf "{{.PYTHON_SRC_DIR}}/LXMF"
|
||||
rm -rf "{{.PYTHON_SRC_DIR}}/LXST"
|
||||
|
||||
# Copy MeshChatX
|
||||
cp -r meshchatx "{{.PYTHON_SRC_DIR}}/"
|
||||
|
||||
# Vendor RNS, LXMF, and LXST from ./misc/ and ./src/
|
||||
cp -r ./misc/RNS "{{.PYTHON_SRC_DIR}}/"
|
||||
cp -r ./misc/LXMF "{{.PYTHON_SRC_DIR}}/"
|
||||
cp -r ./misc/LXST "{{.PYTHON_SRC_DIR}}/"
|
||||
cp -r ./src/RNS "{{.PYTHON_SRC_DIR}}/" || true
|
||||
cp -r ./src/LXMF "{{.PYTHON_SRC_DIR}}/" || true
|
||||
cp -r ./src/LXST "{{.PYTHON_SRC_DIR}}/" || true
|
||||
|
||||
# Copy pycodec2 wheel from ./misc
|
||||
cp "./misc/pycodec2-3.0.1-cp311-cp311-linux_aarch64.whl" "{{.PYTHON_SRC_DIR}}/" || true
|
||||
|
||||
# Copy native libraries from ./misc
|
||||
mkdir -p "{{.JNI_LIBS_DIR}}/arm64-v8a"
|
||||
mkdir -p "{{.JNI_LIBS_DIR}}/armeabi-v7a"
|
||||
cp "./misc/libcodec2-arm64-v8a.so" "{{.JNI_LIBS_DIR}}/arm64-v8a/" || true
|
||||
cp "./misc/libcodec2-armeabi-v7a.so" "{{.JNI_LIBS_DIR}}/armeabi-v7a/" || true
|
||||
|
||||
# Cleanup vendored packages (remove utilities/tests etc if needed, similar to Sideband)
|
||||
rm -rf "{{.PYTHON_SRC_DIR}}/RNS/Utilities/RNS"
|
||||
rm -rf "{{.PYTHON_SRC_DIR}}/LXMF/Utilities/LXMF"
|
||||
rm -rf "{{.PYTHON_SRC_DIR}}/LXST/Utilities/LXST"
|
||||
- |
|
||||
echo "Android build prepared. Don't forget to:"
|
||||
echo "1. Add Chaquopy license to android/local.properties"
|
||||
echo "2. Open android/ in Android Studio or run: task android-build"
|
||||
echo "1. Add Chaquopy license to {{.ANDROID_DIR}}/local.properties"
|
||||
echo "2. Open {{.ANDROID_DIR}}/ in Android Studio or run: task android-build"
|
||||
|
||||
android-build:
|
||||
desc: Build Android APK (requires Android SDK and Chaquopy license)
|
||||
deps: [android-prepare]
|
||||
cmds:
|
||||
- cd android && ./gradlew assembleDebug
|
||||
- cd "{{.ANDROID_DIR}}" && ./gradlew assembleDebug
|
||||
|
||||
android-build-release:
|
||||
desc: Build Android APK (release, requires signing config)
|
||||
deps: [android-prepare]
|
||||
cmds:
|
||||
- cd android && ./gradlew assembleRelease
|
||||
- cd "{{.ANDROID_DIR}}" && ./gradlew assembleRelease
|
||||
|
||||
android-clean:
|
||||
desc: Clean Android build artifacts
|
||||
cmds:
|
||||
- cd android && ./gradlew clean
|
||||
- rm -rf android/app/src/main/python/meshchatx
|
||||
- cd "{{.ANDROID_DIR}}" && ./gradlew clean
|
||||
- rm -rf "{{.PYTHON_SRC_DIR}}/meshchatx"
|
||||
|
||||
flatpak-check-sdk:
|
||||
desc: Check if required Flatpak SDK is installed
|
||||
cmds:
|
||||
- |
|
||||
if ! flatpak info org.freedesktop.Sdk//24.08 >/dev/null 2>&1; then
|
||||
echo "Flatpak SDK 24.08 is not installed."
|
||||
echo "Install it with: flatpak install org.freedesktop.Sdk//24.08"
|
||||
exit 1
|
||||
fi
|
||||
if ! flatpak info org.freedesktop.Platform//24.08 >/dev/null 2>&1; then
|
||||
echo "Flatpak Platform runtime 24.08 is not installed."
|
||||
echo "Install it with: flatpak install org.freedesktop.Platform//24.08"
|
||||
exit 1
|
||||
fi
|
||||
if ! flatpak info org.freedesktop.Sdk.Extension.node20//24.08 >/dev/null 2>&1; then
|
||||
echo "Flatpak Node.js 20 extension is not installed."
|
||||
echo "Install it with: flatpak install org.freedesktop.Sdk.Extension.node20//24.08"
|
||||
exit 1
|
||||
fi
|
||||
echo "Required Flatpak SDK, Platform runtime, and Node.js extension are installed."
|
||||
|
||||
build-flatpak:
|
||||
desc: Build Flatpak package
|
||||
deps: [flatpak-check-sdk]
|
||||
cmds:
|
||||
- flatpak-builder --force-clean build-dir flatpak.json
|
||||
|
||||
install-flatpak:
|
||||
desc: Install Flatpak package locally
|
||||
deps: [build-flatpak]
|
||||
cmds:
|
||||
- flatpak-builder --install --user --force-clean build-dir flatpak.json
|
||||
|
||||
run-flatpak:
|
||||
desc: Run Flatpak application
|
||||
cmds:
|
||||
- flatpak run com.sudoivan.reticulummeshchatx
|
||||
|
||||
@@ -71,7 +71,7 @@ Or open the `android` directory in Android Studio and build from there.
|
||||
The server is configured to:
|
||||
|
||||
- Run on `127.0.0.1:8000` (localhost only)
|
||||
- Use HTTP (not HTTPS) for local WebView access
|
||||
- Use HTTPS for local WebView access
|
||||
- Run in headless mode (no browser launch)
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -15,6 +15,9 @@ android {
|
||||
versionName "3.1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
abiFilters "arm64-v8a", "x86_64"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -39,28 +42,19 @@ android {
|
||||
chaquopy {
|
||||
defaultConfig {
|
||||
version = "3.11"
|
||||
buildPython "python3.11"
|
||||
buildPython "/usr/bin/python3.11"
|
||||
|
||||
pip {
|
||||
install "aiohttp>=3.13.2"
|
||||
install "lxmf>=0.9.3"
|
||||
install "aiohttp==3.10.10"
|
||||
install "psutil>=7.1.3"
|
||||
install "rns>=1.0.4"
|
||||
install "websockets>=15.0.1"
|
||||
install "bcrypt>=5.0.0,<6.0.0"
|
||||
install "bcrypt==3.1.7"
|
||||
install "aiohttp-session>=2.12.1,<3.0.0"
|
||||
install "cryptography>=46.0.3,<47.0.0"
|
||||
install "cryptography==42.0.8"
|
||||
install "requests>=2.32.5,<3.0.0"
|
||||
install "lxst>=0.4.5,<0.5.0"
|
||||
install "numpy==1.26.2"
|
||||
install "ply>=3.11,<4.0"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
python {
|
||||
srcDirs = ["src/main/python"]
|
||||
}
|
||||
install "pycodec2-3.0.1-cp311-cp311-linux_aarch64.whl"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
android/app/pycodec2-3.0.1-cp311-cp311-linux_aarch64.whl
Normal file
@@ -12,9 +12,9 @@
|
||||
<application
|
||||
android:name="com.chaquo.python.android.PyApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:roundIcon="@drawable/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MeshChatX"
|
||||
android:usesCleartextTraffic="true"
|
||||
|
||||
@@ -13,7 +13,7 @@ import com.chaquo.python.android.AndroidPlatform;
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
private WebView webView;
|
||||
private ProgressBar progressBar;
|
||||
private static final String SERVER_URL = "http://127.0.0.1:8000";
|
||||
private static final String SERVER_URL = "https://127.0.0.1:8000";
|
||||
private static final int SERVER_PORT = 8000;
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@@ -49,6 +49,13 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onPageStarted(view, url, favicon);
|
||||
progressBar.setVisibility(android.view.View.VISIBLE);
|
||||
}
|
||||
|
||||
@SuppressLint("WebViewClientOnReceivedSslError")
|
||||
@Override
|
||||
public void onReceivedSslError(WebView view, android.webkit.SslErrorHandler handler, android.net.http.SslError error) {
|
||||
// Ignore SSL certificate errors for localhost
|
||||
handler.proceed();
|
||||
}
|
||||
});
|
||||
|
||||
startMeshChatServer();
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import sys
|
||||
|
||||
|
||||
def start_server(port=8000):
|
||||
try:
|
||||
from meshchatx.meshchat import main
|
||||
|
||||
|
||||
sys.argv = [
|
||||
'meshchat',
|
||||
'--headless',
|
||||
'--host', '127.0.0.1',
|
||||
'--port', str(port),
|
||||
'--no-https'
|
||||
"meshchat",
|
||||
"--headless",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
]
|
||||
|
||||
|
||||
main()
|
||||
except Exception as e:
|
||||
print(f"Error starting MeshChatX server: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
|
||||
13
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3D5AFE"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M30,30h48v48h-48z" />
|
||||
</vector>
|
||||
|
||||
13
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3D5AFE"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M30,30h48v48h-48z" />
|
||||
</vector>
|
||||
|
||||
13
android/app/src/main/res/drawable/ic_launcher_round.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3D5AFE"
|
||||
android:pathData="M54,54m-50,0a50,50 0,1 1,100 0a50,50 0,1 1,-100 0" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M30,30h48v48h-48z" />
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/purple_500"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/purple_500"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -6,15 +6,7 @@ buildscript {
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.7.3'
|
||||
classpath "com.chaquo.python:gradle:15.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url "https://chaquo.com/maven" }
|
||||
classpath "com.chaquo.python:gradle:16.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
cx_setup.py
@@ -8,12 +8,17 @@ from meshchatx.src.version import __version__
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
PUBLIC_DIR = ROOT / "meshchatx" / "public"
|
||||
|
||||
include_files = [
|
||||
(str(PUBLIC_DIR), "public"),
|
||||
("logo", "logo"),
|
||||
]
|
||||
include_files = []
|
||||
|
||||
if (ROOT / "bin").exists():
|
||||
if PUBLIC_DIR.exists() and PUBLIC_DIR.is_dir():
|
||||
include_files.append((str(PUBLIC_DIR), "public"))
|
||||
|
||||
logo_dir = ROOT / "logo"
|
||||
if logo_dir.exists() and logo_dir.is_dir():
|
||||
include_files.append(("logo", "logo"))
|
||||
|
||||
bin_dir = ROOT / "bin"
|
||||
if bin_dir.exists() and bin_dir.is_dir():
|
||||
include_files.append(("bin", "bin"))
|
||||
|
||||
packages = [
|
||||
|
||||
103
flatpak-build.sh
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
export HOME=/tmp/build
|
||||
export XDG_CONFIG_HOME=/tmp/build/.config
|
||||
export XDG_DATA_HOME=/tmp/build/.local/share
|
||||
mkdir -p /tmp/build/.config /tmp/build/.local/share
|
||||
|
||||
NODE_PATHS=(
|
||||
"/usr/lib/sdk/node20/bin"
|
||||
"/usr/lib/sdk/node20/root/usr/bin"
|
||||
"/usr/lib/sdk/node/bin"
|
||||
"/usr/lib/sdk/node/root/usr/bin"
|
||||
)
|
||||
|
||||
NODE_BIN=""
|
||||
NPM_BIN=""
|
||||
|
||||
for path in "${NODE_PATHS[@]}"; do
|
||||
if [ -f "$path/node" ] && [ -f "$path/npm" ]; then
|
||||
NODE_BIN="$path/node"
|
||||
NPM_BIN="$path/npm"
|
||||
export PATH="$path:$PATH"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$NODE_BIN" ] || [ -z "$NPM_BIN" ]; then
|
||||
if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
|
||||
NODE_BIN=$(command -v node)
|
||||
NPM_BIN=$(command -v npm)
|
||||
else
|
||||
echo "Error: Node.js binaries not found. Checking common locations..."
|
||||
find /usr/lib/sdk -name node -type f 2>/dev/null | head -1
|
||||
find /usr/lib/sdk -name npm -type f 2>/dev/null | head -1
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Using Node.js: $NODE_BIN"
|
||||
echo "Using npm: $NPM_BIN"
|
||||
|
||||
PNPM_VERSION="10.0.0"
|
||||
NPM_PREFIX="$HOME/.local"
|
||||
mkdir -p "$NPM_PREFIX"
|
||||
|
||||
export npm_config_prefix="$NPM_PREFIX"
|
||||
$NPM_BIN config set prefix "$NPM_PREFIX"
|
||||
|
||||
echo "Installing pnpm via npm to $NPM_PREFIX..."
|
||||
$NPM_BIN install -g pnpm@${PNPM_VERSION} || exit 1
|
||||
|
||||
export PATH="$NPM_PREFIX/bin:$PATH"
|
||||
|
||||
python3 scripts/sync_version.py
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run build
|
||||
|
||||
mkdir -p /tmp/electron-install
|
||||
cd /tmp/electron-install
|
||||
pnpm init
|
||||
pnpm add electron@39.2.7
|
||||
cd -
|
||||
|
||||
pip3 install poetry
|
||||
poetry install --no-dev
|
||||
poetry run python cx_setup.py build
|
||||
|
||||
mkdir -p /app/bin /app/lib/reticulum-meshchatx /app/share/applications /app/share/icons/hicolor/512x512/apps
|
||||
|
||||
cp -r electron /app/lib/reticulum-meshchatx/
|
||||
cp -r build/exe /app/lib/reticulum-meshchatx/
|
||||
mkdir -p /app/lib/reticulum-meshchatx/electron-bin
|
||||
cp -r /tmp/electron-install/node_modules/electron/* /app/lib/reticulum-meshchatx/electron-bin/
|
||||
cp logo/logo.png /app/share/icons/hicolor/512x512/apps/com.sudoivan.reticulummeshchat.png
|
||||
|
||||
cat > /app/share/applications/com.sudoivan.reticulummeshchat.desktop <<'EOF'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Reticulum MeshChatX
|
||||
Comment=A simple mesh network communications app powered by the Reticulum Network Stack
|
||||
Exec=reticulum-meshchatx
|
||||
Icon=com.sudoivan.reticulummeshchat
|
||||
Categories=Network;InstantMessaging;
|
||||
StartupNotify=true
|
||||
EOF
|
||||
|
||||
cat > /app/bin/reticulum-meshchatx <<'EOF'
|
||||
#!/bin/sh
|
||||
export ELECTRON_IS_DEV=0
|
||||
export APP_PATH=/app/lib/reticulum-meshchatx/electron
|
||||
export EXE_PATH=/app/lib/reticulum-meshchatx/build/exe/ReticulumMeshChatX
|
||||
ELECTRON_BIN=/app/lib/reticulum-meshchatx/electron-bin/dist/electron
|
||||
if [ ! -f "$ELECTRON_BIN" ]; then
|
||||
ELECTRON_BIN=$(find /app/lib/reticulum-meshchatx/electron-bin -name electron -type f 2>/dev/null | head -1)
|
||||
fi
|
||||
cd /app/lib/reticulum-meshchatx/electron
|
||||
exec "$ELECTRON_BIN" . "$@"
|
||||
EOF
|
||||
|
||||
chmod +x /app/bin/reticulum-meshchatx
|
||||
|
||||
37
flatpak.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"app-id": "com.sudoivan.reticulummeshchatx",
|
||||
"runtime": "org.freedesktop.Platform",
|
||||
"runtime-version": "24.08",
|
||||
"sdk": "org.freedesktop.Sdk",
|
||||
"sdk-extensions": ["org.freedesktop.Sdk.Extension.node20"],
|
||||
"build-options": {
|
||||
"env": {
|
||||
"PYTHON": "/usr/bin/python3"
|
||||
}
|
||||
},
|
||||
"command": "reticulum-meshchatx",
|
||||
"finish-args": [
|
||||
"--share=network",
|
||||
"--socket=wayland",
|
||||
"--socket=x11",
|
||||
"--socket=pulseaudio",
|
||||
"--device=all",
|
||||
"--filesystem=home",
|
||||
"--filesystem=host",
|
||||
"--talk-name=org.freedesktop.NetworkManager",
|
||||
"--talk-name=org.freedesktop.secrets"
|
||||
],
|
||||
"modules": [
|
||||
{
|
||||
"name": "reticulum-meshchatx",
|
||||
"buildsystem": "simple",
|
||||
"build-commands": ["bash flatpak-build.sh"],
|
||||
"sources": [
|
||||
{
|
||||
"type": "dir",
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -132,11 +132,18 @@ class ConfigManager:
|
||||
)
|
||||
|
||||
# ringtone config
|
||||
self.custom_ringtone_enabled = self.BoolConfig(self, "custom_ringtone_enabled", False)
|
||||
self.custom_ringtone_enabled = self.BoolConfig(
|
||||
self, "custom_ringtone_enabled", False
|
||||
)
|
||||
self.ringtone_filename = self.StringConfig(self, "ringtone_filename", None)
|
||||
|
||||
# telephony config
|
||||
self.do_not_disturb_enabled = self.BoolConfig(self, "do_not_disturb_enabled", False)
|
||||
self.do_not_disturb_enabled = self.BoolConfig(
|
||||
self, "do_not_disturb_enabled", False
|
||||
)
|
||||
self.telephone_allow_calls_from_contacts_only = self.BoolConfig(
|
||||
self, "telephone_allow_calls_from_contacts_only", False
|
||||
)
|
||||
|
||||
# map config
|
||||
self.map_offline_enabled = self.BoolConfig(self, "map_offline_enabled", False)
|
||||
|
||||
@@ -63,4 +63,3 @@ class ContactsDAO:
|
||||
"SELECT * FROM contacts WHERE remote_identity_hash = ?",
|
||||
(remote_identity_hash,),
|
||||
)
|
||||
|
||||
|
||||
@@ -229,3 +229,37 @@ class MiscDAO:
|
||||
"SELECT * FROM archived_pages WHERE id = ?",
|
||||
(archive_id,),
|
||||
)
|
||||
|
||||
# Notifications
|
||||
def add_notification(self, type, remote_hash, title, content):
|
||||
now = datetime.now(UTC)
|
||||
timestamp = datetime.now(UTC).timestamp()
|
||||
self.provider.execute(
|
||||
"INSERT INTO notifications (type, remote_hash, title, content, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(type, remote_hash, title, content, timestamp, now),
|
||||
)
|
||||
|
||||
def get_notifications(self, filter_unread=False, limit=50):
|
||||
query = "SELECT * FROM notifications"
|
||||
params = []
|
||||
if filter_unread:
|
||||
query += " WHERE is_viewed = 0"
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
return self.provider.fetchall(query, params)
|
||||
|
||||
def mark_notifications_as_viewed(self, notification_ids=None):
|
||||
if notification_ids:
|
||||
placeholders = ", ".join(["?"] * len(notification_ids))
|
||||
self.provider.execute(
|
||||
f"UPDATE notifications SET is_viewed = 1 WHERE id IN ({placeholders})",
|
||||
notification_ids,
|
||||
)
|
||||
else:
|
||||
self.provider.execute("UPDATE notifications SET is_viewed = 1")
|
||||
|
||||
def get_unread_notification_count(self):
|
||||
row = self.provider.fetchone(
|
||||
"SELECT COUNT(*) as count FROM notifications WHERE is_viewed = 0",
|
||||
)
|
||||
return row["count"] if row else 0
|
||||
|
||||
@@ -8,10 +8,14 @@ class RingtoneDAO:
|
||||
self.provider = provider
|
||||
|
||||
def get_all(self):
|
||||
return self.provider.fetchall("SELECT * FROM ringtones ORDER BY created_at DESC")
|
||||
return self.provider.fetchall(
|
||||
"SELECT * FROM ringtones ORDER BY created_at DESC"
|
||||
)
|
||||
|
||||
def get_by_id(self, ringtone_id):
|
||||
return self.provider.fetchone("SELECT * FROM ringtones WHERE id = ?", (ringtone_id,))
|
||||
return self.provider.fetchone(
|
||||
"SELECT * FROM ringtones WHERE id = ?", (ringtone_id,)
|
||||
)
|
||||
|
||||
def get_primary(self):
|
||||
return self.provider.fetchone("SELECT * FROM ringtones WHERE is_primary = 1")
|
||||
@@ -22,7 +26,9 @@ class RingtoneDAO:
|
||||
display_name = filename
|
||||
|
||||
# check if this is the first ringtone, if so make it primary
|
||||
count = self.provider.fetchone("SELECT COUNT(*) as count FROM ringtones")["count"]
|
||||
count = self.provider.fetchone("SELECT COUNT(*) as count FROM ringtones")[
|
||||
"count"
|
||||
]
|
||||
is_primary = 1 if count == 0 else 0
|
||||
|
||||
cursor = self.provider.execute(
|
||||
@@ -35,7 +41,9 @@ class RingtoneDAO:
|
||||
now = datetime.now(UTC)
|
||||
if is_primary == 1:
|
||||
# reset others
|
||||
self.provider.execute("UPDATE ringtones SET is_primary = 0, updated_at = ?", (now,))
|
||||
self.provider.execute(
|
||||
"UPDATE ringtones SET is_primary = 0, updated_at = ?", (now,)
|
||||
)
|
||||
|
||||
if display_name is not None and is_primary is not None:
|
||||
self.provider.execute(
|
||||
@@ -63,4 +71,3 @@ class RingtoneDAO:
|
||||
self.update(next_ringtone["id"], is_primary=1)
|
||||
else:
|
||||
self.provider.execute("DELETE FROM ringtones WHERE id = ?", (ringtone_id,))
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
|
||||
|
||||
|
||||
class DatabaseSchema:
|
||||
LATEST_VERSION = 19
|
||||
LATEST_VERSION = 20
|
||||
|
||||
def __init__(self, provider: DatabaseProvider):
|
||||
self.provider = provider
|
||||
@@ -247,6 +247,18 @@ class DatabaseSchema:
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"notifications": """
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT,
|
||||
remote_hash TEXT,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
is_viewed INTEGER DEFAULT 0,
|
||||
timestamp REAL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
}
|
||||
|
||||
for table_name, create_sql in tables.items():
|
||||
@@ -556,6 +568,26 @@ class DatabaseSchema:
|
||||
"CREATE INDEX IF NOT EXISTS idx_call_history_remote_name ON call_history(remote_identity_name)",
|
||||
)
|
||||
|
||||
if current_version < 20:
|
||||
self.provider.execute("""
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT,
|
||||
remote_hash TEXT,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
is_viewed INTEGER DEFAULT 0,
|
||||
timestamp REAL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
self.provider.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_notifications_remote_hash ON notifications(remote_hash)",
|
||||
)
|
||||
self.provider.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_notifications_timestamp ON notifications(timestamp)",
|
||||
)
|
||||
|
||||
# Update version in config
|
||||
self.provider.execute(
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,12 @@ class TelemetryDAO:
|
||||
self.provider = provider
|
||||
|
||||
def upsert_telemetry(
|
||||
self, destination_hash, timestamp, data, received_from=None, physical_link=None,
|
||||
self,
|
||||
destination_hash,
|
||||
timestamp,
|
||||
data,
|
||||
received_from=None,
|
||||
physical_link=None,
|
||||
):
|
||||
now = datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ class MapManager:
|
||||
self._local = threading.local()
|
||||
self._metadata_cache = None
|
||||
self._export_progress = {}
|
||||
self._export_cancelled = set()
|
||||
|
||||
def get_connection(self, path):
|
||||
if not hasattr(self._local, "connections"):
|
||||
@@ -158,6 +159,21 @@ class MapManager:
|
||||
def get_export_status(self, export_id):
|
||||
return self._export_progress.get(export_id)
|
||||
|
||||
def cancel_export(self, export_id):
|
||||
if export_id in self._export_progress:
|
||||
self._export_cancelled.add(export_id)
|
||||
# If it's already failed or completed, just clean up
|
||||
status = self._export_progress[export_id].get("status")
|
||||
if status in ["completed", "failed"]:
|
||||
file_path = self._export_progress[export_id].get("file_path")
|
||||
if file_path and os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
del self._export_progress[export_id]
|
||||
if export_id in self._export_cancelled:
|
||||
self._export_cancelled.remove(export_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _run_export(self, export_id, bbox, min_zoom, max_zoom, name):
|
||||
# bbox: [min_lon, min_lat, max_lon, max_lat]
|
||||
min_lon, min_lat, max_lon, max_lat = bbox
|
||||
@@ -206,7 +222,15 @@ class MapManager:
|
||||
|
||||
for x in range(x1, x2 + 1):
|
||||
for y in range(y1, y2 + 1):
|
||||
# check if we should stop (if we add a cancel mechanism)
|
||||
# check if we should stop
|
||||
if export_id in self._export_cancelled:
|
||||
conn.close()
|
||||
if os.path.exists(dest_path):
|
||||
os.remove(dest_path)
|
||||
if export_id in self._export_progress:
|
||||
del self._export_progress[export_id]
|
||||
self._export_cancelled.remove(export_id)
|
||||
return
|
||||
|
||||
# download tile
|
||||
tile_url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
|
||||
@@ -56,7 +56,7 @@ class MessageHandler:
|
||||
params = [local_hash, local_hash, like_term, like_term, like_term, like_term]
|
||||
return self.db.provider.fetchall(query, params)
|
||||
|
||||
def get_conversations(self, local_hash):
|
||||
def get_conversations(self, local_hash, filter_unread=False):
|
||||
# Implementation moved from get_conversations DAO but with local_hash filter
|
||||
query = """
|
||||
SELECT m1.* FROM lxmf_messages m1
|
||||
@@ -69,8 +69,7 @@ class MessageHandler:
|
||||
GROUP BY peer_hash
|
||||
) m2 ON (CASE WHEN m1.source_hash = ? THEN m1.destination_hash ELSE m1.source_hash END = m2.peer_hash
|
||||
AND m1.timestamp = m2.max_ts)
|
||||
WHERE m1.source_hash = ? OR m1.destination_hash = ?
|
||||
ORDER BY m1.timestamp DESC
|
||||
WHERE (m1.source_hash = ? OR m1.destination_hash = ?)
|
||||
"""
|
||||
params = [
|
||||
local_hash,
|
||||
@@ -80,4 +79,11 @@ class MessageHandler:
|
||||
local_hash,
|
||||
local_hash,
|
||||
]
|
||||
|
||||
if filter_unread:
|
||||
query += " AND EXISTS (SELECT 1 FROM lxmf_messages m3 WHERE (m3.source_hash = m2.peer_hash AND m3.destination_hash = ?) AND m3.state = 'received' AND m3.is_incoming = 1)"
|
||||
params.append(local_hash)
|
||||
|
||||
query += " ORDER BY m1.timestamp DESC"
|
||||
|
||||
return self.db.provider.fetchall(query, params)
|
||||
|
||||
@@ -34,6 +34,7 @@ class RingtoneManager:
|
||||
raise RuntimeError(msg)
|
||||
|
||||
import secrets
|
||||
|
||||
filename = f"ringtone_{secrets.token_hex(8)}.opus"
|
||||
opus_path = os.path.join(self.storage_dir, filename)
|
||||
|
||||
@@ -63,4 +64,3 @@ class RingtoneManager:
|
||||
|
||||
def get_ringtone_path(self, filename):
|
||||
return os.path.join(self.storage_dir, filename)
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class VoicemailManager:
|
||||
os.makedirs(self.recordings_dir, exist_ok=True)
|
||||
|
||||
self.is_recording = False
|
||||
self.is_greeting_recording = False
|
||||
self.recording_pipeline = None
|
||||
self.recording_sink = None
|
||||
self.recording_start_time = None
|
||||
@@ -36,6 +37,9 @@ class VoicemailManager:
|
||||
|
||||
self.on_new_voicemail_callback = None
|
||||
|
||||
# stabilization delay for voicemail greeting
|
||||
self.STABILIZATION_DELAY = 2.5
|
||||
|
||||
# Paths to executables
|
||||
self.espeak_path = self._find_espeak()
|
||||
self.ffmpeg_path = self._find_ffmpeg()
|
||||
@@ -215,7 +219,7 @@ class VoicemailManager:
|
||||
and telephone.active_call
|
||||
and telephone.active_call.get_remote_identity().hash
|
||||
== caller_identity.hash
|
||||
and telephone.call_status == 4 # Ringing
|
||||
and telephone.call_status == 4 # Ringing
|
||||
):
|
||||
RNS.log(
|
||||
f"Auto-answering call from {RNS.prettyhexrep(caller_identity.hash)} for voicemail",
|
||||
@@ -252,33 +256,68 @@ class VoicemailManager:
|
||||
greeting_path = os.path.join(self.greetings_dir, "greeting.opus")
|
||||
if not os.path.exists(greeting_path):
|
||||
# Fallback if no greeting generated yet
|
||||
self.generate_greeting(self.config.voicemail_greeting.get())
|
||||
if self.has_espeak and self.has_ffmpeg:
|
||||
try:
|
||||
self.generate_greeting(self.config.voicemail_greeting.get())
|
||||
except Exception as e:
|
||||
RNS.log(
|
||||
f"Voicemail: Could not generate initial greeting: {e}",
|
||||
RNS.LOG_ERROR,
|
||||
)
|
||||
else:
|
||||
RNS.log(
|
||||
"Voicemail: espeak-ng or ffmpeg missing, cannot generate greeting",
|
||||
RNS.LOG_WARNING,
|
||||
)
|
||||
|
||||
def session_job():
|
||||
try:
|
||||
# 1. Play greeting
|
||||
try:
|
||||
greeting_source = OpusFileSource(greeting_path, target_frame_ms=60)
|
||||
# Attach to transmit mixer
|
||||
greeting_pipeline = Pipeline(
|
||||
source=greeting_source,
|
||||
codec=Null(),
|
||||
sink=telephone.transmit_mixer,
|
||||
)
|
||||
greeting_pipeline.start()
|
||||
# Wait for link to stabilize
|
||||
RNS.log(
|
||||
f"Voicemail: Waiting {self.STABILIZATION_DELAY}s for link stabilization...",
|
||||
RNS.LOG_DEBUG,
|
||||
)
|
||||
time.sleep(self.STABILIZATION_DELAY)
|
||||
|
||||
# Wait for greeting to finish
|
||||
while greeting_source.running:
|
||||
time.sleep(0.1)
|
||||
if not telephone.active_call:
|
||||
return
|
||||
|
||||
greeting_pipeline.stop()
|
||||
except Exception as e:
|
||||
if not telephone.active_call:
|
||||
RNS.log(
|
||||
f"Voicemail: Could not play greeting (libs missing?): {e}",
|
||||
RNS.LOG_ERROR,
|
||||
"Voicemail: Call ended during stabilization delay",
|
||||
RNS.LOG_DEBUG,
|
||||
)
|
||||
return
|
||||
|
||||
# 1. Play greeting
|
||||
if os.path.exists(greeting_path):
|
||||
try:
|
||||
greeting_source = OpusFileSource(
|
||||
greeting_path, target_frame_ms=60
|
||||
)
|
||||
# Attach to transmit mixer
|
||||
greeting_pipeline = Pipeline(
|
||||
source=greeting_source,
|
||||
codec=Null(),
|
||||
sink=telephone.transmit_mixer,
|
||||
)
|
||||
greeting_pipeline.start()
|
||||
|
||||
# Wait for greeting to finish
|
||||
while greeting_source.running:
|
||||
time.sleep(0.1)
|
||||
if not telephone.active_call:
|
||||
greeting_pipeline.stop()
|
||||
return
|
||||
|
||||
greeting_pipeline.stop()
|
||||
except Exception as e:
|
||||
RNS.log(
|
||||
f"Voicemail: Could not play greeting (libs missing?): {e}",
|
||||
RNS.LOG_ERROR,
|
||||
)
|
||||
else:
|
||||
RNS.log("Voicemail: No greeting available to play", RNS.LOG_WARNING)
|
||||
|
||||
if not telephone.active_call:
|
||||
return
|
||||
|
||||
# 2. Play beep
|
||||
beep_source = LXST.ToneSource(
|
||||
@@ -292,6 +331,9 @@ class VoicemailManager:
|
||||
time.sleep(0.5)
|
||||
beep_source.stop()
|
||||
|
||||
if not telephone.active_call:
|
||||
return
|
||||
|
||||
# 3. Start recording
|
||||
self.start_recording(caller_identity)
|
||||
|
||||
@@ -393,6 +435,7 @@ class VoicemailManager:
|
||||
os.remove(filepath)
|
||||
|
||||
self.is_recording = False
|
||||
self.is_greeting_recording = False
|
||||
self.recording_start_time = None
|
||||
self.recording_remote_identity = None
|
||||
self.recording_filename = None
|
||||
@@ -400,3 +443,53 @@ class VoicemailManager:
|
||||
except Exception as e:
|
||||
RNS.log(f"Error stopping recording: {e}", RNS.LOG_ERROR)
|
||||
self.is_recording = False
|
||||
|
||||
def start_greeting_recording(self):
|
||||
telephone = self.telephone_manager.telephone
|
||||
if not telephone:
|
||||
return
|
||||
|
||||
# Ensure we have audio input
|
||||
if not telephone.audio_input:
|
||||
RNS.log(
|
||||
"Voicemail: No audio input available for recording greeting",
|
||||
RNS.LOG_ERROR,
|
||||
)
|
||||
return
|
||||
|
||||
temp_wav = os.path.join(self.greetings_dir, "temp_greeting.wav")
|
||||
if os.path.exists(temp_wav):
|
||||
os.remove(temp_wav)
|
||||
|
||||
try:
|
||||
self.greeting_recording_sink = OpusFileSink(
|
||||
os.path.join(self.greetings_dir, "greeting.opus")
|
||||
)
|
||||
self.greeting_recording_sink.samplerate = 48000
|
||||
|
||||
self.greeting_recording_pipeline = Pipeline(
|
||||
source=telephone.audio_input,
|
||||
codec=Null(),
|
||||
sink=self.greeting_recording_sink,
|
||||
)
|
||||
self.greeting_recording_pipeline.start()
|
||||
self.is_greeting_recording = True
|
||||
RNS.log("Voicemail: Started recording greeting from mic", RNS.LOG_DEBUG)
|
||||
except Exception as e:
|
||||
RNS.log(
|
||||
f"Voicemail: Failed to start greeting recording: {e}", RNS.LOG_ERROR
|
||||
)
|
||||
|
||||
def stop_greeting_recording(self):
|
||||
if not self.is_greeting_recording:
|
||||
return
|
||||
|
||||
try:
|
||||
self.greeting_recording_pipeline.stop()
|
||||
self.greeting_recording_sink = None
|
||||
self.greeting_recording_pipeline = None
|
||||
self.is_greeting_recording = False
|
||||
RNS.log("Voicemail: Stopped recording greeting from mic", RNS.LOG_DEBUG)
|
||||
except Exception as e:
|
||||
RNS.log(f"Voicemail: Error stopping greeting recording: {e}", RNS.LOG_ERROR)
|
||||
self.is_greeting_recording = False
|
||||
|
||||
@@ -116,22 +116,39 @@
|
||||
|
||||
<!-- sidebar -->
|
||||
<div
|
||||
class="fixed inset-y-0 left-0 z-[70] w-72 transform transition-transform duration-300 ease-in-out sm:relative sm:z-0 sm:flex sm:translate-x-0"
|
||||
:class="isSidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
class="fixed inset-y-0 left-0 z-[70] transform transition-all duration-300 ease-in-out sm:relative sm:z-0 sm:flex sm:translate-x-0"
|
||||
:class="[
|
||||
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
isSidebarCollapsed ? 'w-20' : 'w-72',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="flex h-full w-full flex-col overflow-y-auto border-r border-gray-200/70 bg-white dark:border-zinc-800 dark:bg-zinc-900 backdrop-blur"
|
||||
>
|
||||
<!-- toggle button for desktop -->
|
||||
<div class="hidden sm:flex justify-end p-2 border-b border-gray-100 dark:border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded-lg text-gray-500 hover:bg-gray-100 dark:text-zinc-400 dark:hover:bg-zinc-800 transition-colors"
|
||||
@click="isSidebarCollapsed = !isSidebarCollapsed"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="isSidebarCollapsed ? 'chevron-right' : 'chevron-left'"
|
||||
class="size-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- navigation -->
|
||||
<div class="flex-1">
|
||||
<ul class="py-3 pr-2 space-y-1">
|
||||
<!-- messages -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'messages' }">
|
||||
<SidebarLink :to="{ name: 'messages' }" :is-collapsed="isSidebarCollapsed">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon
|
||||
icon-name="message-text"
|
||||
class="w-6 h-6 dark:text-white"
|
||||
class="w-6 h-6 text-gray-700 dark:text-white"
|
||||
/>
|
||||
</template>
|
||||
<template #text>
|
||||
@@ -145,9 +162,12 @@
|
||||
|
||||
<!-- nomad network -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'nomadnetwork' }">
|
||||
<SidebarLink :to="{ name: 'nomadnetwork' }" :is-collapsed="isSidebarCollapsed">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="earth" class="w-6 h-6" />
|
||||
<MaterialDesignIcon
|
||||
icon-name="earth"
|
||||
class="w-6 h-6 text-gray-700 dark:text-gray-200"
|
||||
/>
|
||||
</template>
|
||||
<template #text>{{ $t("app.nomad_network") }}</template>
|
||||
</SidebarLink>
|
||||
@@ -155,9 +175,12 @@
|
||||
|
||||
<!-- map -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'map' }">
|
||||
<SidebarLink :to="{ name: 'map' }" :is-collapsed="isSidebarCollapsed">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="map" class="w-6 h-6" />
|
||||
<MaterialDesignIcon
|
||||
icon-name="map"
|
||||
class="w-6 h-6 text-gray-700 dark:text-gray-200"
|
||||
/>
|
||||
</template>
|
||||
<template #text>{{ $t("app.map") }}</template>
|
||||
</SidebarLink>
|
||||
@@ -165,9 +188,12 @@
|
||||
|
||||
<!-- archives -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'archives' }">
|
||||
<SidebarLink :to="{ name: 'archives' }" :is-collapsed="isSidebarCollapsed">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="archive" class="w-6 h-6" />
|
||||
<MaterialDesignIcon
|
||||
icon-name="archive"
|
||||
class="w-6 h-6 text-gray-700 dark:text-gray-200"
|
||||
/>
|
||||
</template>
|
||||
<template #text>{{ $t("app.archives") }}</template>
|
||||
</SidebarLink>
|
||||
@@ -175,9 +201,12 @@
|
||||
|
||||
<!-- telephone -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'call' }">
|
||||
<SidebarLink :to="{ name: 'call' }" :is-collapsed="isSidebarCollapsed">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="phone" class="w-6 h-6" />
|
||||
<MaterialDesignIcon
|
||||
icon-name="phone"
|
||||
class="w-6 h-6 text-gray-700 dark:text-gray-200"
|
||||
/>
|
||||
</template>
|
||||
<template #text>{{ $t("app.audio_calls") }}</template>
|
||||
</SidebarLink>
|
||||
@@ -185,9 +214,12 @@
|
||||
|
||||
<!-- interfaces -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'interfaces' }">
|
||||
<SidebarLink :to="{ name: 'interfaces' }" :is-collapsed="isSidebarCollapsed">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="router" class="w-6 h-6" />
|
||||
<MaterialDesignIcon
|
||||
icon-name="router"
|
||||
class="w-6 h-6 text-gray-700 dark:text-gray-200"
|
||||
/>
|
||||
</template>
|
||||
<template #text>{{ $t("app.interfaces") }}</template>
|
||||
</SidebarLink>
|
||||
@@ -195,9 +227,15 @@
|
||||
|
||||
<!-- network visualiser -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'network-visualiser' }">
|
||||
<SidebarLink
|
||||
:to="{ name: 'network-visualiser' }"
|
||||
:is-collapsed="isSidebarCollapsed"
|
||||
>
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="diagram-projector" class="w-6 h-6" />
|
||||
<MaterialDesignIcon
|
||||
icon-name="diagram-projector"
|
||||
class="w-6 h-6 text-gray-700 dark:text-gray-200"
|
||||
/>
|
||||
</template>
|
||||
<template #text>{{ $t("app.network_visualiser") }}</template>
|
||||
</SidebarLink>
|
||||
@@ -205,9 +243,12 @@
|
||||
|
||||
<!-- tools -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'tools' }">
|
||||
<SidebarLink :to="{ name: 'tools' }" :is-collapsed="isSidebarCollapsed">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="wrench" class="size-6" />
|
||||
<MaterialDesignIcon
|
||||
icon-name="wrench"
|
||||
class="size-6 text-gray-700 dark:text-gray-200"
|
||||
/>
|
||||
</template>
|
||||
<template #text>{{ $t("app.tools") }}</template>
|
||||
</SidebarLink>
|
||||
@@ -215,9 +256,12 @@
|
||||
|
||||
<!-- settings -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'settings' }">
|
||||
<SidebarLink :to="{ name: 'settings' }" :is-collapsed="isSidebarCollapsed">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="cog" class="size-6" />
|
||||
<MaterialDesignIcon
|
||||
icon-name="cog"
|
||||
class="size-6 text-gray-700 dark:text-gray-200"
|
||||
/>
|
||||
</template>
|
||||
<template #text>{{ $t("app.settings") }}</template>
|
||||
</SidebarLink>
|
||||
@@ -225,9 +269,12 @@
|
||||
|
||||
<!-- identities -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'identities' }">
|
||||
<SidebarLink :to="{ name: 'identities' }" :is-collapsed="isSidebarCollapsed">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="account-multiple" class="size-6" />
|
||||
<MaterialDesignIcon
|
||||
icon-name="account-multiple"
|
||||
class="size-6 text-gray-700 dark:text-gray-200"
|
||||
/>
|
||||
</template>
|
||||
<template #text>{{ $t("app.identities") }}</template>
|
||||
</SidebarLink>
|
||||
@@ -235,9 +282,12 @@
|
||||
|
||||
<!-- info -->
|
||||
<li>
|
||||
<SidebarLink :to="{ name: 'about' }">
|
||||
<SidebarLink :to="{ name: 'about' }" :is-collapsed="isSidebarCollapsed">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon icon-name="information" class="size-6" />
|
||||
<MaterialDesignIcon
|
||||
icon-name="information"
|
||||
class="size-6 text-gray-700 dark:text-gray-200"
|
||||
/>
|
||||
</template>
|
||||
<template #text>{{ $t("app.about") }}</template>
|
||||
</SidebarLink>
|
||||
@@ -255,7 +305,7 @@
|
||||
class="flex text-gray-700 p-3 cursor-pointer"
|
||||
@click="isShowingMyIdentitySection = !isShowingMyIdentitySection"
|
||||
>
|
||||
<div class="my-auto mr-2">
|
||||
<div class="my-auto mr-2 shrink-0">
|
||||
<RouterLink :to="{ name: 'profile.icon' }" @click.stop>
|
||||
<LxmfUserIcon
|
||||
:icon-name="config?.lxmf_user_icon_name"
|
||||
@@ -264,8 +314,10 @@
|
||||
/>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="my-auto dark:text-white">{{ $t("app.my_identity") }}</div>
|
||||
<div class="my-auto ml-auto">
|
||||
<div v-if="!isSidebarCollapsed" class="my-auto dark:text-white truncate">
|
||||
{{ $t("app.my_identity") }}
|
||||
</div>
|
||||
<div v-if="!isSidebarCollapsed" class="my-auto ml-auto shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500"
|
||||
@@ -276,7 +328,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isShowingMyIdentitySection"
|
||||
v-if="isShowingMyIdentitySection && !isSidebarCollapsed"
|
||||
class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
<div class="p-2">
|
||||
@@ -287,19 +339,19 @@
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-800 dark:border-zinc-600 dark:text-zinc-200 dark:focus:ring-blue-400 dark:focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-2 dark:border-zinc-900 overflow-hidden">
|
||||
<div class="p-2 dark:border-zinc-900 overflow-hidden text-xs">
|
||||
<div>{{ $t("app.identity_hash") }}</div>
|
||||
<div
|
||||
class="text-sm text-gray-700 dark:text-zinc-400 truncate font-mono"
|
||||
class="text-[10px] text-gray-700 dark:text-zinc-400 truncate font-mono"
|
||||
:title="config.identity_hash"
|
||||
>
|
||||
{{ config.identity_hash }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 dark:border-zinc-900 overflow-hidden">
|
||||
<div class="p-2 dark:border-zinc-900 overflow-hidden text-xs">
|
||||
<div>{{ $t("app.lxmf_address") }}</div>
|
||||
<div
|
||||
class="text-sm text-gray-700 dark:text-zinc-400 truncate font-mono"
|
||||
class="text-[10px] text-gray-700 dark:text-zinc-400 truncate font-mono"
|
||||
:title="config.lxmf_address_hash"
|
||||
>
|
||||
{{ config.lxmf_address_hash }}
|
||||
@@ -317,11 +369,13 @@
|
||||
class="flex text-gray-700 p-3 cursor-pointer dark:text-white"
|
||||
@click="isShowingAnnounceSection = !isShowingAnnounceSection"
|
||||
>
|
||||
<div class="my-auto mr-2">
|
||||
<div class="my-auto mr-2 shrink-0">
|
||||
<MaterialDesignIcon icon-name="radio" class="size-6" />
|
||||
</div>
|
||||
<div class="my-auto">{{ $t("app.announce") }}</div>
|
||||
<div class="ml-auto">
|
||||
<div v-if="!isSidebarCollapsed" class="my-auto truncate">
|
||||
{{ $t("app.announce") }}
|
||||
</div>
|
||||
<div v-if="!isSidebarCollapsed" class="ml-auto shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500"
|
||||
@@ -332,7 +386,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isShowingAnnounceSection"
|
||||
v-if="isShowingAnnounceSection && !isSidebarCollapsed"
|
||||
class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
<div class="p-2 dark:border-zinc-800">
|
||||
@@ -350,7 +404,7 @@
|
||||
<option value="43200">Every 12 Hours</option>
|
||||
<option value="86400">Every 24 Hours</option>
|
||||
</select>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-100">
|
||||
<div class="text-[10px] text-gray-700 dark:text-zinc-100 mt-1">
|
||||
<span v-if="config.last_announced_at">{{
|
||||
$t("app.last_announced", {
|
||||
time: formatSecondsAgo(config.last_announced_at),
|
||||
@@ -365,14 +419,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPopoutMode" class="flex flex-1 min-w-0 overflow-hidden">
|
||||
<div class="flex flex-1 min-w-0 overflow-hidden">
|
||||
<RouterView class="flex-1 min-w-0 h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<CallOverlay
|
||||
v-if="activeCall || isCallEnded || wasDeclined"
|
||||
v-if="(activeCall || isCallEnded || wasDeclined) && $route.name !== 'call'"
|
||||
:active-call="activeCall || lastCall"
|
||||
:is-ended="isCallEnded"
|
||||
:was-declined="wasDeclined"
|
||||
@@ -443,6 +497,7 @@ export default {
|
||||
isShowingAnnounceSection: true,
|
||||
|
||||
isSidebarOpen: false,
|
||||
isSidebarCollapsed: false,
|
||||
|
||||
isSwitchingIdentity: false,
|
||||
|
||||
@@ -496,6 +551,13 @@ export default {
|
||||
if (newConfig && newConfig.custom_ringtone_enabled !== undefined) {
|
||||
this.updateRingtonePlayer();
|
||||
}
|
||||
if (newConfig && newConfig.theme) {
|
||||
if (newConfig.theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
@@ -551,6 +613,13 @@ export default {
|
||||
case "config": {
|
||||
this.config = json.config;
|
||||
this.displayName = json.config.display_name;
|
||||
if (this.config?.theme) {
|
||||
if (this.config.theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "announced": {
|
||||
@@ -559,6 +628,9 @@ export default {
|
||||
break;
|
||||
}
|
||||
case "telephone_ringing": {
|
||||
if (this.config?.do_not_disturb_enabled) {
|
||||
break;
|
||||
}
|
||||
NotificationUtils.showIncomingCallNotification();
|
||||
this.updateTelephoneStatus();
|
||||
this.playRingtone();
|
||||
@@ -616,6 +688,13 @@ export default {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/config`);
|
||||
this.config = response.data.config;
|
||||
if (this.config?.theme) {
|
||||
if (this.config.theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing if failed to load config
|
||||
console.log(e);
|
||||
@@ -817,6 +896,11 @@ export default {
|
||||
this.wasDeclined = false;
|
||||
this.lastCall = null;
|
||||
if (this.endedTimeout) clearTimeout(this.endedTimeout);
|
||||
} else if (!this.endedTimeout) {
|
||||
// If no call and no ended state timeout active, ensure everything is reset
|
||||
this.isCallEnded = false;
|
||||
this.wasDeclined = false;
|
||||
this.lastCall = null;
|
||||
}
|
||||
} catch {
|
||||
// do nothing on error
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="iconName"
|
||||
class="p-2 rounded"
|
||||
class="p-2 rounded-full"
|
||||
:style="{ color: iconForegroundColour, 'background-color': iconBackgroundColour }"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="iconName" :class="iconClass" />
|
||||
</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-full">
|
||||
<MaterialDesignIcon icon-name="account-outline" :class="iconClass" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -94,17 +94,9 @@
|
||||
</div>
|
||||
<div
|
||||
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
|
||||
:title="
|
||||
notification.latest_message_preview ??
|
||||
notification.latest_message_title ??
|
||||
'No message preview'
|
||||
"
|
||||
:title="notification.latest_message_preview ?? notification.content ?? 'No preview'"
|
||||
>
|
||||
{{
|
||||
notification.latest_message_preview ??
|
||||
notification.latest_message_title ??
|
||||
"No message preview"
|
||||
}}
|
||||
{{ notification.latest_message_preview ?? notification.content ?? "No preview" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,14 +137,11 @@ export default {
|
||||
isDropdownOpen: false,
|
||||
isLoading: false,
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
reloadInterval: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
unreadCount() {
|
||||
return this.notifications.length;
|
||||
},
|
||||
},
|
||||
computed: {},
|
||||
beforeUnmount() {
|
||||
if (this.reloadInterval) {
|
||||
clearInterval(this.reloadInterval);
|
||||
@@ -182,15 +171,16 @@ export default {
|
||||
async loadNotifications() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/lxmf/conversations`, {
|
||||
const response = await window.axios.get(`/api/v1/notifications`, {
|
||||
params: {
|
||||
filter_unread: true,
|
||||
unread: true,
|
||||
limit: 10,
|
||||
},
|
||||
});
|
||||
const newNotifications = response.data.conversations || [];
|
||||
const newNotifications = response.data.notifications || [];
|
||||
|
||||
this.notifications = newNotifications;
|
||||
this.unreadCount = response.data.unread_count || 0;
|
||||
} catch (e) {
|
||||
console.error("Failed to load notifications", e);
|
||||
this.notifications = [];
|
||||
@@ -203,9 +193,14 @@ export default {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const destination_hashes = this.notifications.map((n) => n.destination_hash);
|
||||
const destination_hashes = this.notifications
|
||||
.filter((n) => n.type === "lxmf_message")
|
||||
.map((n) => n.destination_hash);
|
||||
const notification_ids = this.notifications.filter((n) => n.type !== "lxmf_message").map((n) => n.id);
|
||||
|
||||
await window.axios.post("/api/v1/notifications/mark-as-viewed", {
|
||||
destination_hashes: destination_hashes,
|
||||
notification_ids: notification_ids,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to mark notifications as viewed", e);
|
||||
@@ -213,10 +208,17 @@ export default {
|
||||
},
|
||||
onNotificationClick(notification) {
|
||||
this.closeDropdown();
|
||||
this.$router.push({
|
||||
name: "messages",
|
||||
params: { destinationHash: notification.destination_hash },
|
||||
});
|
||||
if (notification.type === "lxmf_message") {
|
||||
this.$router.push({
|
||||
name: "messages",
|
||||
params: { destinationHash: notification.destination_hash },
|
||||
});
|
||||
} else if (notification.type === "telephone_missed_call") {
|
||||
this.$router.push({
|
||||
name: "call",
|
||||
query: { tab: "history" },
|
||||
});
|
||||
}
|
||||
},
|
||||
formatTimeAgo(datetimeString) {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-zinc-700',
|
||||
]"
|
||||
class="w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"
|
||||
class="w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500 overflow-hidden"
|
||||
@click="handleNavigate($event, navigate)"
|
||||
>
|
||||
<span class="my-auto">
|
||||
<span class="my-auto shrink-0">
|
||||
<slot name="icon"></slot>
|
||||
</span>
|
||||
<span class="my-auto flex w-full">
|
||||
<span v-if="!isCollapsed" class="my-auto flex w-full truncate">
|
||||
<slot name="text"></slot>
|
||||
</span>
|
||||
</a>
|
||||
@@ -29,6 +29,10 @@ export default {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["click"],
|
||||
methods: {
|
||||
|
||||
@@ -3,33 +3,35 @@
|
||||
<div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
|
||||
<!-- header -->
|
||||
<div
|
||||
class="flex items-center px-4 py-4 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-sm"
|
||||
class="flex flex-col sm:flex-row sm:items-center px-4 py-4 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-sm gap-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg shrink-0">
|
||||
<MaterialDesignIcon icon-name="archive" class="size-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{{ $t("app.archives") }}</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t("archives.description") }}</p>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white truncate">{{ $t("app.archives") }}</h1>
|
||||
<p class="text-xs sm:text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{{ $t("archives.description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2 sm:gap-4">
|
||||
<div class="relative w-32 sm:w-64 md:w-80">
|
||||
<div class="flex items-center gap-2 sm:ml-auto">
|
||||
<div class="relative flex-1 sm:w-64 md:w-80">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
class="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all text-sm"
|
||||
:placeholder="$t('archives.search_placeholder')"
|
||||
@input="onSearchInput"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="p-2 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||
class="p-2 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors shrink-0"
|
||||
:title="$t('common.refresh')"
|
||||
@click="getArchives"
|
||||
>
|
||||
|
||||
@@ -321,6 +321,13 @@
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="text-xs font-bold text-red-500 hover:text-red-600 uppercase tracking-tighter"
|
||||
@click="cancelActiveExport"
|
||||
>
|
||||
{{ $t("common.cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="exportStatus.status !== 'completed' && exportStatus.status !== 'failed'">
|
||||
@@ -1124,6 +1131,20 @@ export default {
|
||||
this.selectedBbox = null;
|
||||
this.isExportMode = false;
|
||||
},
|
||||
async cancelActiveExport() {
|
||||
if (!this.exportId) {
|
||||
this.exportStatus = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/map/export/${this.exportId}`);
|
||||
this.exportStatus = null;
|
||||
this.exportId = null;
|
||||
ToastUtils.success("Export cancelled");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to cancel export");
|
||||
}
|
||||
},
|
||||
async startExport() {
|
||||
if (!this.selectedBbox) return;
|
||||
this.isExporting = true;
|
||||
|
||||
@@ -56,7 +56,13 @@
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
|
||||
<!-- destination hash -->
|
||||
<div class="inline-block mr-1">
|
||||
<div><{{ selectedPeer.destination_hash }}></div>
|
||||
<div
|
||||
class="cursor-pointer hover:text-blue-500 transition-colors"
|
||||
:title="selectedPeer.destination_hash"
|
||||
@click="copyHash(selectedPeer.destination_hash)"
|
||||
>
|
||||
{{ formatDestinationHash(selectedPeer.destination_hash) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inline-block">
|
||||
@@ -757,7 +763,7 @@
|
||||
<div class="flex flex-wrap gap-2 items-center mt-2">
|
||||
<button type="button" class="attachment-action-button" @click="addFilesToMessage">
|
||||
<MaterialDesignIcon icon-name="paperclip-plus" class="w-4 h-4" />
|
||||
<span>{{ $t("messages.add_files") }}</span>
|
||||
<span class="hidden sm:inline">{{ $t("messages.add_files") }}</span>
|
||||
</button>
|
||||
<AddImageButton @add-image="onImageSelected" />
|
||||
<AddAudioButton
|
||||
@@ -774,7 +780,7 @@
|
||||
@click="shareLocation"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="map-marker" class="w-4 h-4" />
|
||||
<span>{{ $t("messages.location") }}</span>
|
||||
<span class="hidden sm:inline">{{ $t("messages.location") }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -783,7 +789,7 @@
|
||||
@click="requestLocation"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="crosshairs-question" class="w-4 h-4" />
|
||||
<span>{{ $t("messages.request") }}</span>
|
||||
<span class="hidden sm:inline">{{ $t("messages.request") }}</span>
|
||||
</button>
|
||||
<div class="ml-auto my-auto">
|
||||
<SendMessageButton
|
||||
@@ -1951,6 +1957,18 @@ export default {
|
||||
formatTimeAgo: function (datetimeString) {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
},
|
||||
formatDestinationHash(hash) {
|
||||
return Utils.formatDestinationHash(hash);
|
||||
},
|
||||
async copyHash(hash) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(hash);
|
||||
ToastUtils.success("Hash copied to clipboard");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error("Failed to copy hash");
|
||||
}
|
||||
},
|
||||
formatBytes: function (bytes) {
|
||||
return Utils.formatBytes(bytes);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
|
||||
<!-- Compact Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-900/50 backdrop-blur-sm shrink-0"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-teal-100 dark:bg-teal-900/30 p-1.5 rounded-xl shrink-0">
|
||||
<MaterialDesignIcon icon-name="code-tags" class="size-5 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<h1
|
||||
class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider hidden sm:block truncate"
|
||||
>
|
||||
{{ $t("tools.micron_editor.title") }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="secondary-chip !py-1 !px-3" @click="downloadFile">
|
||||
<MaterialDesignIcon icon-name="download" class="w-3.5 h-3.5" />
|
||||
<span class="hidden sm:inline">{{ $t("tools.micron_editor.download") }}</span>
|
||||
</button>
|
||||
<button v-if="isMobileView" type="button" class="primary-chip !py-1 !px-3" @click="toggleView">
|
||||
<MaterialDesignIcon :icon-name="showEditor ? 'eye' : 'pencil'" class="w-3.5 h-3.5" />
|
||||
{{ showEditor ? $t("tools.micron_editor.view_preview") : $t("tools.micron_editor.edit") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Editor Pane -->
|
||||
<div
|
||||
:class="[
|
||||
'flex-1 overflow-hidden flex flex-col',
|
||||
isMobileView && !showEditor ? 'hidden' : '',
|
||||
!isMobileView ? 'border-r border-gray-200 dark:border-zinc-800' : '',
|
||||
]"
|
||||
>
|
||||
<textarea
|
||||
ref="editorRef"
|
||||
v-model="content"
|
||||
class="flex-1 w-full bg-white dark:bg-zinc-900 text-gray-900 dark:text-white p-4 font-mono text-sm resize-none focus:outline-none"
|
||||
:placeholder="$t('tools.micron_editor.placeholder')"
|
||||
@input="handleInput"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Preview Pane (Always dark to match NomadNet browser vibe) -->
|
||||
<div
|
||||
:class="[
|
||||
'flex-1 overflow-hidden flex flex-col bg-zinc-950',
|
||||
isMobileView && showEditor ? 'hidden' : '',
|
||||
]"
|
||||
>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
ref="previewRef"
|
||||
class="flex-1 overflow-auto text-zinc-100 p-4 font-mono text-sm whitespace-pre-wrap break-words nodeContainer"
|
||||
v-html="renderedContent"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import MicronParser from "micron-parser";
|
||||
|
||||
export default {
|
||||
name: "MicronEditorPage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
content: "",
|
||||
renderedContent: "",
|
||||
showEditor: true,
|
||||
isMobileView: false,
|
||||
storageKey: "micron_editor_content",
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadContent();
|
||||
this.handleResize();
|
||||
window.addEventListener("resize", this.handleResize);
|
||||
this.handleInput();
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener("resize", this.handleResize);
|
||||
},
|
||||
methods: {
|
||||
handleResize() {
|
||||
this.isMobileView = window.innerWidth < 1024;
|
||||
if (!this.isMobileView) {
|
||||
this.showEditor = true;
|
||||
}
|
||||
},
|
||||
handleInput() {
|
||||
try {
|
||||
// Always use dark mode for parser since preview pane is now always dark
|
||||
// to match the NomadNet browser's classic appearance.
|
||||
const parser = new MicronParser(true);
|
||||
this.renderedContent = parser.convertMicronToHtml(this.content);
|
||||
} catch (error) {
|
||||
console.error("Error rendering micron:", error);
|
||||
this.renderedContent = `<p style="color: red;">Error rendering: ${error.message}</p>`;
|
||||
}
|
||||
this.saveContent();
|
||||
},
|
||||
toggleView() {
|
||||
this.showEditor = !this.showEditor;
|
||||
},
|
||||
saveContent() {
|
||||
try {
|
||||
localStorage.setItem(this.storageKey, this.content);
|
||||
} catch (error) {
|
||||
console.warn("Failed to save content to localStorage:", error);
|
||||
}
|
||||
},
|
||||
loadContent() {
|
||||
try {
|
||||
const saved = localStorage.getItem(this.storageKey);
|
||||
if (saved) {
|
||||
this.content = saved;
|
||||
} else {
|
||||
this.content = this.getDefaultContent();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load content from localStorage:", error);
|
||||
this.content = this.getDefaultContent();
|
||||
}
|
||||
},
|
||||
getDefaultContent() {
|
||||
const b = "`";
|
||||
return `${b}Ffd0
|
||||
${b}=
|
||||
_ _
|
||||
(_) (_)
|
||||
_ __ ___ _ ___ _ __ ___ _ __ ______ _ __ __ _ _ __ ___ ___ _ __ _ ___
|
||||
| '_ \` _ \\| |/ __| '__/ _ \\| '_ \\______| '_ \\ / _\` | '__/ __|/ _ \\ '__| / __|
|
||||
| | | | | | | (__| | | (_) | | | | | |_) | (_| | | \\__ \\\\ __/ |_ | \\__ \\\\
|
||||
|_| |_| |_|_|\\___|_| \\___/|_| |_| | .__/ \\__,_|_| |___/\\___|_(_)| |___/
|
||||
| | _/ |
|
||||
|_| |__/
|
||||
|
||||
${b}=
|
||||
${b}f
|
||||
|
||||
${b}!Welcome to Micron Editor${b}!
|
||||
-
|
||||
Micron is a lightweight, terminal-friendly monospace markdown format used in Reticulum applications.
|
||||
|
||||
${b}!With Micron, you can${b}${b}:
|
||||
|
||||
${b}c Align${b}b
|
||||
|
||||
${b}r text,
|
||||
|
||||
${b}a
|
||||
${b}c
|
||||
set ${b}B005 backgrounds, ${b}b and ${b}*${b} ${b}B777${b}Ffffcombine any number of${b}f${b}b${b}_${b}_ ${b}Ff00f${b}Ff80o${b}Ffd0r${b}F9f0m${b}F0f2a${b}F0fdt${b}F07ft${b}F43fi${b}F70fn${b}Fe0fg ${b}ftags.
|
||||
${b}${b}
|
||||
|
||||
>Getting Started
|
||||
|
||||
Start editing your Micron markup in the editor pane. The preview will update automatically.
|
||||
|
||||
>Formatting
|
||||
|
||||
Text can be ${b}!bold${b}! by using \\${b}!, \\${b}_, and \\${b}*.
|
||||
|
||||
>Colors
|
||||
|
||||
Foreground colors: ${b}Ff00${b}Ff80o${b}Ffd0r${b}F9f0m${b}F0f2a${b}F0fdt${b}F07ft${b}F43fi${b}F70fn${b}Fe0fg${b}f
|
||||
Background colors: ${b}Bf00${b}Bf80o${b}Bfd0r${b}B9f0m${b}B0f2a${b}B0fdt${b}B07ft${b}B43fi${b}B70fn${b}Be0fg${b}b
|
||||
|
||||
>Links
|
||||
|
||||
Create links with \\${b}[ tag: ${b}_${b}[Example Link${b}example.com]${b}]${b}_
|
||||
|
||||
>Literals
|
||||
|
||||
Use \\${b}= to start/end literal blocks that won't be interpreted.
|
||||
|
||||
${b}=
|
||||
This is a literal block
|
||||
${b}=
|
||||
`;
|
||||
},
|
||||
downloadFile() {
|
||||
const blob = new Blob([this.content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "micron.mu";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nodeContainer {
|
||||
font-family:
|
||||
Roboto Mono Nerd Font,
|
||||
monospace;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
:deep(.Mu-nl) {
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.Mu-mnt) {
|
||||
display: inline-block;
|
||||
width: 0.6em;
|
||||
text-align: center;
|
||||
white-space: pre;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
:deep(.Mu-mws) {
|
||||
text-decoration: inherit;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
:deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -47,10 +47,10 @@
|
||||
@drop.prevent="onFavouriteDrop($event, favourite)"
|
||||
@dragend="onFavouriteDragEnd"
|
||||
>
|
||||
<div class="favourite-card__icon">
|
||||
<div class="favourite-card__icon flex-shrink-0">
|
||||
<MaterialDesignIcon icon-name="server-network" class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="text-sm font-semibold text-gray-900 dark:text-white truncate"
|
||||
:title="favourite.display_name"
|
||||
|
||||
@@ -1,102 +1,201 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-0 dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto space-y-2 p-2">
|
||||
<!-- info -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="overflow-y-auto">
|
||||
<div class="max-w-4xl mx-auto p-4 space-y-6">
|
||||
<!-- Header with Preview -->
|
||||
<div
|
||||
class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold"
|
||||
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
|
||||
>
|
||||
Customise your Profile Icon
|
||||
</div>
|
||||
<div class="text-gray-900 dark:text-gray-100">
|
||||
<div class="text-sm p-2">
|
||||
<ul class="list-disc list-inside">
|
||||
<li>Personalise your profile with a custom coloured icon.</li>
|
||||
<li>This icon will be sent in all of your outgoing messages.</li>
|
||||
<li>When you send someone a message, they will see your new icon.</li>
|
||||
<li>
|
||||
You can
|
||||
<span class="cursor-pointer underline text-blue-500" @click="removeProfileIcon"
|
||||
>remove your icon</span
|
||||
>, however it will still show for anyone that already received it.
|
||||
</li>
|
||||
</ul>
|
||||
<div class="p-6 border-b border-gray-200 dark:border-zinc-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Profile Icon Customizer</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-zinc-400 mt-1">
|
||||
Customize your profile icon that appears in all your messages
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!hasChanges || isSaving"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="
|
||||
hasChanges && !isSaving
|
||||
? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:border-blue-500 dark:hover:bg-blue-600'
|
||||
: 'bg-gray-100 text-gray-700 border-gray-300 dark:bg-zinc-800 dark:text-zinc-300 dark:border-zinc-700'
|
||||
"
|
||||
@click="saveChanges"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
v-if="isSaving"
|
||||
icon-name="refresh"
|
||||
class="size-4 animate-spin"
|
||||
/>
|
||||
<MaterialDesignIcon v-else icon-name="content-save" class="size-4" />
|
||||
{{ isSaving ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!hasChanges || isSaving"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="resetChanges"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="refresh" class="size-4" />
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col items-center justify-center space-y-4">
|
||||
<div class="text-sm font-medium text-gray-700 dark:text-zinc-300">Preview</div>
|
||||
<div class="p-8 bg-gray-50 dark:bg-zinc-800 rounded-2xl">
|
||||
<LxmfUserIcon
|
||||
:icon-name="iconName"
|
||||
:icon-foreground-colour="iconForegroundColour"
|
||||
:icon-background-colour="iconBackgroundColour"
|
||||
icon-class="size-16"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-400 text-center max-w-md">
|
||||
This is how your icon will appear to others when you send messages
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- colours -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<!-- Color Selection -->
|
||||
<div
|
||||
class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold"
|
||||
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
|
||||
>
|
||||
Select your Colours
|
||||
</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
<!-- background colour -->
|
||||
<div class="p-2 flex space-x-2">
|
||||
<div class="flex my-auto">
|
||||
<ColourPickerDropdown v-model:colour="iconBackgroundColour" />
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Background Colour</div>
|
||||
<div class="text-sm text-gray-900 dark:text-gray-100">{{ iconBackgroundColour }}</div>
|
||||
</div>
|
||||
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Colors</h3>
|
||||
</div>
|
||||
|
||||
<!-- icon colour -->
|
||||
<div class="p-2 flex space-x-2">
|
||||
<div class="flex my-auto">
|
||||
<ColourPickerDropdown v-model:colour="iconForegroundColour" />
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
|
||||
Background Color
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<ColourPickerDropdown v-model:colour="iconBackgroundColour" />
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="iconBackgroundColour"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="#e5e7eb"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Icon Colour</div>
|
||||
<div class="text-sm text-gray-900 dark:text-gray-100">{{ iconForegroundColour }}</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
|
||||
Icon Color
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<ColourPickerDropdown v-model:colour="iconForegroundColour" />
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="iconForegroundColour"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="#6b7280"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- search icons -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<!-- Icon Selection -->
|
||||
<div
|
||||
class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold"
|
||||
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
|
||||
>
|
||||
Select your Icon
|
||||
</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
<div class="flex p-1">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
:placeholder="`Search ${iconNames.length} icons...`"
|
||||
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="p-4 border-b border-gray-200 dark:border-zinc-800">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Icon</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700">
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
:placeholder="`Search ${iconNames.length} icons...`"
|
||||
class="w-full px-4 py-3 text-sm border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 placeholder-gray-400 dark:placeholder-zinc-500 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<MaterialDesignIcon
|
||||
icon-name="magnify"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 size-5 text-gray-400 dark:text-zinc-500 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="mdiIconName of searchedIconNames"
|
||||
:key="mdiIconName"
|
||||
class="flex space-x-2 p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-700"
|
||||
@click="onIconClick(mdiIconName)"
|
||||
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 max-h-[500px] overflow-y-auto p-1"
|
||||
>
|
||||
<div class="my-auto">
|
||||
<div
|
||||
v-for="mdiIconName of searchedIconNames"
|
||||
:key="mdiIconName"
|
||||
class="flex flex-col items-center justify-center p-4 rounded-lg border-2 cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-zinc-800 hover:border-blue-500 dark:hover:border-blue-500"
|
||||
:class="
|
||||
iconName === mdiIconName
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-zinc-700'
|
||||
"
|
||||
@click="onIconClick(mdiIconName)"
|
||||
>
|
||||
<LxmfUserIcon
|
||||
:icon-name="mdiIconName"
|
||||
:icon-foreground-colour="iconForegroundColour"
|
||||
:icon-background-colour="iconBackgroundColour"
|
||||
icon-class="size-8"
|
||||
/>
|
||||
<div
|
||||
class="mt-2 text-xs text-center text-gray-600 dark:text-zinc-400 truncate w-full"
|
||||
:title="mdiIconName"
|
||||
>
|
||||
{{ mdiIconName }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto">{{ mdiIconName }}</div>
|
||||
</div>
|
||||
<div v-if="searchedIconNames.length === 0" class="p-1 text-sm text-gray-500">
|
||||
<div
|
||||
v-if="searchedIconNames.length === 0"
|
||||
class="text-center py-8 text-sm text-gray-500 dark:text-zinc-400"
|
||||
>
|
||||
No icons match your search.
|
||||
</div>
|
||||
<div v-if="searchedIconNames.length === maxSearchResults" class="p-1 text-sm text-gray-500">
|
||||
A maximum of {{ maxSearchResults }} icons are shown.
|
||||
<div
|
||||
v-if="searchedIconNames.length === maxSearchResults"
|
||||
class="text-center py-2 text-xs text-gray-500 dark:text-zinc-400"
|
||||
>
|
||||
Showing first {{ maxSearchResults }} results. Refine your search to see more.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Icon Section -->
|
||||
<div
|
||||
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
|
||||
>
|
||||
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove Icon</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<p class="text-sm text-gray-600 dark:text-zinc-400 mb-4">
|
||||
Remove your profile icon. Anyone who has already received it will continue to see it until
|
||||
you send them a new icon.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-red-300 dark:border-red-800 bg-white dark:bg-zinc-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
@click="removeProfileIcon"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete-outline" class="size-4" />
|
||||
Remove Icon
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,115 +204,196 @@
|
||||
<script>
|
||||
import * as mdi from "@mdi/js";
|
||||
import LxmfUserIcon from "../LxmfUserIcon.vue";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import ColourPickerDropdown from "../ColourPickerDropdown.vue";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "ProfileIconPage",
|
||||
components: {
|
||||
ColourPickerDropdown,
|
||||
LxmfUserIcon,
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config: null,
|
||||
iconName: null,
|
||||
iconForegroundColour: null,
|
||||
iconBackgroundColour: null,
|
||||
|
||||
originalIconName: null,
|
||||
originalIconForegroundColour: null,
|
||||
originalIconBackgroundColour: null,
|
||||
|
||||
search: "",
|
||||
maxSearchResults: 100,
|
||||
maxSearchResults: 200,
|
||||
iconNames: [],
|
||||
|
||||
isSaving: false,
|
||||
autoSaveTimeout: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
searchedIconNames() {
|
||||
const searchLower = this.search.toLowerCase();
|
||||
return this.iconNames
|
||||
.filter((iconName) => {
|
||||
return iconName.includes(this.search);
|
||||
return iconName.toLowerCase().includes(searchLower);
|
||||
})
|
||||
.slice(0, this.maxSearchResults);
|
||||
},
|
||||
hasChanges() {
|
||||
return (
|
||||
this.iconName !== this.originalIconName ||
|
||||
this.iconForegroundColour !== this.originalIconForegroundColour ||
|
||||
this.iconBackgroundColour !== this.originalIconBackgroundColour
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
config() {
|
||||
// update ui when config is updated
|
||||
this.iconName = this.config.lxmf_user_icon_name;
|
||||
this.iconForegroundColour = this.config.lxmf_user_icon_foreground_colour || "#6b7280";
|
||||
this.iconBackgroundColour = this.config.lxmf_user_icon_background_colour || "#e5e7eb";
|
||||
if (this.config) {
|
||||
this.iconName = this.config.lxmf_user_icon_name || null;
|
||||
this.iconForegroundColour = this.config.lxmf_user_icon_foreground_colour || "#6b7280";
|
||||
this.iconBackgroundColour = this.config.lxmf_user_icon_background_colour || "#e5e7eb";
|
||||
|
||||
this.saveOriginalValues();
|
||||
}
|
||||
},
|
||||
iconForegroundColour() {
|
||||
this.debouncedAutoSave();
|
||||
},
|
||||
iconBackgroundColour() {
|
||||
this.debouncedAutoSave();
|
||||
},
|
||||
iconName() {
|
||||
this.debouncedAutoSave();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
|
||||
// load icon names
|
||||
this.iconNames = Object.keys(mdi).map((mdiIcon) => {
|
||||
return mdiIcon
|
||||
.replace(/^mdi/, "") // Remove the "mdi" prefix
|
||||
.replace(/([a-z])([A-Z])/g, "$1-$2") // Add a hyphen between lowercase and uppercase letters
|
||||
.toLowerCase(); // Convert the entire string to lowercase
|
||||
.replace(/^mdi/, "")
|
||||
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.autoSaveTimeout) {
|
||||
clearTimeout(this.autoSaveTimeout);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
saveOriginalValues() {
|
||||
this.originalIconName = this.iconName;
|
||||
this.originalIconForegroundColour = this.iconForegroundColour;
|
||||
this.originalIconBackgroundColour = this.iconBackgroundColour;
|
||||
},
|
||||
debouncedAutoSave() {
|
||||
if (this.autoSaveTimeout) {
|
||||
clearTimeout(this.autoSaveTimeout);
|
||||
}
|
||||
|
||||
this.autoSaveTimeout = setTimeout(() => {
|
||||
if (this.hasChanges && this.iconName && this.iconForegroundColour && this.iconBackgroundColour) {
|
||||
this.saveChanges(true);
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
async getConfig() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/config");
|
||||
this.config = response.data.config;
|
||||
} catch (e) {
|
||||
// do nothing if failed to load config
|
||||
console.log(e);
|
||||
ToastUtils.error("Failed to load configuration");
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
async updateConfig(config) {
|
||||
async updateConfig(config, silent = false) {
|
||||
try {
|
||||
const response = await window.axios.patch("/api/v1/config", config);
|
||||
this.config = response.data.config;
|
||||
this.saveOriginalValues();
|
||||
|
||||
if (!silent) {
|
||||
ToastUtils.success("Profile icon saved successfully");
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
ToastUtils.error("Failed to save config!");
|
||||
console.log(e);
|
||||
if (!silent) {
|
||||
ToastUtils.error("Failed to save profile icon");
|
||||
}
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async onIconClick(iconName) {
|
||||
// ensure foreground colour set
|
||||
if (this.iconForegroundColour == null) {
|
||||
DialogUtils.alert("Please select an icon colour first");
|
||||
async saveChanges(silent = false) {
|
||||
if (!this.hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ensure background colour set
|
||||
if (this.iconBackgroundColour == null) {
|
||||
DialogUtils.alert("Please select a background colour first");
|
||||
if (!this.iconForegroundColour || !this.iconBackgroundColour) {
|
||||
ToastUtils.warning("Please select both background and icon colors");
|
||||
return;
|
||||
}
|
||||
|
||||
// confirm user wants to update their icon
|
||||
if (!(await DialogUtils.confirm("Are you sure you want to set this as your profile icon?"))) {
|
||||
if (!this.iconName) {
|
||||
ToastUtils.warning("Please select an icon");
|
||||
return;
|
||||
}
|
||||
|
||||
// save icon appearance
|
||||
await this.updateConfig({
|
||||
lxmf_user_icon_name: iconName,
|
||||
lxmf_user_icon_foreground_colour: this.iconForegroundColour,
|
||||
lxmf_user_icon_background_colour: this.iconBackgroundColour,
|
||||
});
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
const success = await this.updateConfig(
|
||||
{
|
||||
lxmf_user_icon_name: this.iconName,
|
||||
lxmf_user_icon_foreground_colour: this.iconForegroundColour,
|
||||
lxmf_user_icon_background_colour: this.iconBackgroundColour,
|
||||
},
|
||||
silent
|
||||
);
|
||||
|
||||
if (success && !silent) {
|
||||
ToastUtils.success("Profile icon saved successfully");
|
||||
}
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
},
|
||||
resetChanges() {
|
||||
if (!this.hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iconName = this.originalIconName;
|
||||
this.iconForegroundColour = this.originalIconForegroundColour;
|
||||
this.iconBackgroundColour = this.originalIconBackgroundColour;
|
||||
|
||||
ToastUtils.info("Changes reset to saved values");
|
||||
},
|
||||
onIconClick(iconName) {
|
||||
this.iconName = iconName;
|
||||
},
|
||||
async removeProfileIcon() {
|
||||
// confirm user wants to remove their icon
|
||||
if (
|
||||
!(await DialogUtils.confirm(
|
||||
"Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon."
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.isSaving = true;
|
||||
|
||||
// remove profile icon
|
||||
await this.updateConfig({
|
||||
lxmf_user_icon_name: null,
|
||||
lxmf_user_icon_foreground_colour: null,
|
||||
lxmf_user_icon_background_colour: null,
|
||||
});
|
||||
try {
|
||||
const success = await this.updateConfig({
|
||||
lxmf_user_icon_name: null,
|
||||
lxmf_user_icon_foreground_colour: null,
|
||||
lxmf_user_icon_background_colour: null,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
ToastUtils.success("Profile icon removed successfully");
|
||||
}
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -98,6 +98,19 @@
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="{ name: 'micron-editor' }" class="tool-card glass-card">
|
||||
<div class="tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200">
|
||||
<MaterialDesignIcon icon-name="code-tags" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("tools.micron_editor.title") }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("tools.micron_editor.description") }}
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<a target="_blank" href="/rnode-flasher/index.html" class="tool-card glass-card">
|
||||
<div
|
||||
class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"interfaces": "Schnittstellen",
|
||||
"tools": "Werkzeuge",
|
||||
"settings": "Einstellungen",
|
||||
"identities": "Identitäten",
|
||||
"about": "Über",
|
||||
"my_identity": "Meine Identität",
|
||||
"identity_hash": "Identitäts-Hash",
|
||||
@@ -110,6 +111,7 @@
|
||||
"open": "Öffnen",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"block": "Blockieren",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"add": "Hinzufügen",
|
||||
@@ -121,6 +123,24 @@
|
||||
"auto_recover": "Automatisch wiederherstellen",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"identities": {
|
||||
"title": "Identitäten",
|
||||
"manage": "Verwalten und wechseln Sie zwischen mehreren Reticulum-Identitäten.",
|
||||
"new_identity": "Neue Identität",
|
||||
"generate_fresh": "Eine neue Reticulum-Identität generieren.",
|
||||
"display_name": "Anzeigename",
|
||||
"display_name_hint": "z.B. Geheimagent",
|
||||
"current": "Aktuell",
|
||||
"switch": "Zu dieser Identität wechseln",
|
||||
"delete": "Identität löschen",
|
||||
"no_identities": "Keine Identitäten gefunden",
|
||||
"create_first": "Erstellen Sie eine neue Identität, um zu beginnen.",
|
||||
"switch_confirm": "Zu Identität \"{name}\" wechseln? Die Anwendung muss neu gestartet werden.",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie die Identität \"{name}\" löschen möchten? Dies kann nicht rückgängig gemacht werden.",
|
||||
"switched": "Identität gewechselt. Die Anwendung wird jetzt neu gestartet, um die Änderungen zu übernehmen.",
|
||||
"created": "Identität erfolgreich erstellt",
|
||||
"deleted": "Identität gelöscht"
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"version": "v{version}",
|
||||
@@ -280,6 +300,11 @@
|
||||
"no_active_chat": "Kein aktiver Chat",
|
||||
"select_peer_or_enter_address": "Wählen Sie einen Peer aus der Seitenleiste oder geben Sie unten eine Adresse ein",
|
||||
"add_files": "Dateien hinzufügen",
|
||||
"location": "Standort",
|
||||
"share_location": "Standort teilen",
|
||||
"request_location": "Standort anfordern",
|
||||
"view_on_map": "Auf Karte anzeigen",
|
||||
"request": "Anfordern",
|
||||
"recording": "Aufnahme: {duration}",
|
||||
"nomad_network_node": "Nomad Network Knoten",
|
||||
"toggle_source": "Quellcode umschalten"
|
||||
@@ -381,11 +406,21 @@
|
||||
},
|
||||
"forwarder": {
|
||||
"title": "Weiterleiter",
|
||||
"description": "LXMF-Weiterleitung im SimpleLogin-Stil mit Rückpfad-Routing."
|
||||
"description": "LXMF-Weiterleitung mit Rückpfad-Routing."
|
||||
},
|
||||
"rnode_flasher": {
|
||||
"title": "RNode Flasher",
|
||||
"description": "RNode-Adapter flashen und aktualisieren, ohne die Kommandozeile zu berühren."
|
||||
},
|
||||
"micron_editor": {
|
||||
"title": "Micron Editor",
|
||||
"description": "Micron-Markup bearbeiten und mit Live-Rendering anzeigen.",
|
||||
"editor": "Editor",
|
||||
"preview": "Vorschau",
|
||||
"download": "Herunterladen",
|
||||
"edit": "Bearbeiten",
|
||||
"view_preview": "Vorschau anzeigen",
|
||||
"placeholder": "Geben Sie hier Ihr Micron-Markup ein..."
|
||||
}
|
||||
},
|
||||
"ping": {
|
||||
@@ -604,6 +639,10 @@
|
||||
"failed_to_initiate_call": "Anruf konnte nicht initiiert werden",
|
||||
"enter_identity_hash_to_call_error": "Geben Sie einen Identitäts-Hash ein, um anzurufen",
|
||||
"failed_to_save_settings": "Einstellungen konnten nicht gespeichert werden",
|
||||
"do_not_disturb": "Nicht stören",
|
||||
"load_more": "Mehr laden",
|
||||
"search_history": "Verlauf durchsuchen...",
|
||||
"allow_calls_from_contacts_only": "Nur Anrufe von Kontakten zulassen",
|
||||
"settings_saved": "Einstellungen gespeichert",
|
||||
"greeting_generated_successfully": "Begrüßung erfolgreich generiert",
|
||||
"failed_to_generate_greeting": "Begrüßung konnte nicht generiert werden",
|
||||
|
||||
@@ -406,11 +406,21 @@
|
||||
},
|
||||
"forwarder": {
|
||||
"title": "Forwarder",
|
||||
"description": "SimpleLogin-style LXMF forwarding with return path routing."
|
||||
"description": "LXMF forwarding with return path routing."
|
||||
},
|
||||
"rnode_flasher": {
|
||||
"title": "RNode Flasher",
|
||||
"description": "Flash and update RNode adapters without touching the command line."
|
||||
},
|
||||
"micron_editor": {
|
||||
"title": "Micron Editor",
|
||||
"description": "Edit and preview Micron markup with live rendering.",
|
||||
"editor": "Editor",
|
||||
"preview": "Preview",
|
||||
"download": "Download",
|
||||
"edit": "Edit",
|
||||
"view_preview": "View Preview",
|
||||
"placeholder": "Enter your Micron markup here..."
|
||||
}
|
||||
},
|
||||
"ping": {
|
||||
@@ -632,6 +642,7 @@
|
||||
"do_not_disturb": "Do Not Disturb",
|
||||
"load_more": "Load More",
|
||||
"search_history": "Search history...",
|
||||
"allow_calls_from_contacts_only": "Only allow calls from contacts",
|
||||
"settings_saved": "Settings saved",
|
||||
"greeting_generated_successfully": "Greeting generated successfully",
|
||||
"failed_to_generate_greeting": "Failed to generate greeting",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"interfaces": "Интерфейсы",
|
||||
"tools": "Инструменты",
|
||||
"settings": "Настройки",
|
||||
"identities": "Личности",
|
||||
"about": "О программе",
|
||||
"my_identity": "Моя личность",
|
||||
"identity_hash": "Хеш личности",
|
||||
@@ -110,6 +111,7 @@
|
||||
"open": "Открыть",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"block": "Заблокировать",
|
||||
"delete": "Удалить",
|
||||
"edit": "Изменить",
|
||||
"add": "Добавить",
|
||||
@@ -121,6 +123,24 @@
|
||||
"auto_recover": "Автовосстановление",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить."
|
||||
},
|
||||
"identities": {
|
||||
"title": "Личности",
|
||||
"manage": "Управляйте и переключайтесь между несколькими личностями Reticulum.",
|
||||
"new_identity": "Новая личность",
|
||||
"generate_fresh": "Создать новую личность Reticulum.",
|
||||
"display_name": "Отображаемое имя",
|
||||
"display_name_hint": "например, Секретный агент",
|
||||
"current": "Текущая",
|
||||
"switch": "Переключиться на эту личность",
|
||||
"delete": "Удалить личность",
|
||||
"no_identities": "Личности не найдены",
|
||||
"create_first": "Создайте новую личность, чтобы начать.",
|
||||
"switch_confirm": "Переключиться на личность \"{name}\"? Приложение будет перезапущено.",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить личность \"{name}\"? Это действие нельзя отменить.",
|
||||
"switched": "Личность изменена. Приложение будет перезапущено для применения изменений.",
|
||||
"created": "Личность успешно создана",
|
||||
"deleted": "Личность удалена"
|
||||
},
|
||||
"about": {
|
||||
"title": "О программе",
|
||||
"version": "v{version}",
|
||||
@@ -280,6 +300,11 @@
|
||||
"no_active_chat": "Нет активного чата",
|
||||
"select_peer_or_enter_address": "Выберите собеседника из списка или введите адрес ниже",
|
||||
"add_files": "Добавить файлы",
|
||||
"location": "Местоположение",
|
||||
"share_location": "Поделиться местоположением",
|
||||
"request_location": "Запросить местоположение",
|
||||
"view_on_map": "Показать на карте",
|
||||
"request": "Запрос",
|
||||
"recording": "Запись: {duration}",
|
||||
"nomad_network_node": "Узел Nomad Network",
|
||||
"toggle_source": "Исходный код"
|
||||
@@ -386,6 +411,16 @@
|
||||
"rnode_flasher": {
|
||||
"title": "RNode Flasher",
|
||||
"description": "Прошивка и обновление адаптеров RNode без использования командной строки."
|
||||
},
|
||||
"micron_editor": {
|
||||
"title": "Редактор Micron",
|
||||
"description": "Редактирование и предпросмотр разметки Micron с живым рендерингом.",
|
||||
"editor": "Редактор",
|
||||
"preview": "Предпросмотр",
|
||||
"download": "Скачать",
|
||||
"edit": "Редактировать",
|
||||
"view_preview": "Просмотр",
|
||||
"placeholder": "Введите разметку Micron здесь..."
|
||||
}
|
||||
},
|
||||
"ping": {
|
||||
@@ -604,6 +639,10 @@
|
||||
"failed_to_initiate_call": "Не удалось инициировать звонок",
|
||||
"enter_identity_hash_to_call_error": "Введите хеш личности для звонка",
|
||||
"failed_to_save_settings": "Не удалось сохранить настройки",
|
||||
"do_not_disturb": "Не беспокоить",
|
||||
"load_more": "Загрузить еще",
|
||||
"search_history": "Поиск по истории...",
|
||||
"allow_calls_from_contacts_only": "Разрешить звонки только от контактов",
|
||||
"settings_saved": "Настройки сохранены",
|
||||
"greeting_generated_successfully": "Приветствие успешно создано",
|
||||
"failed_to_generate_greeting": "Не удалось создать приветствие",
|
||||
|
||||
@@ -156,6 +156,11 @@ const router = createRouter({
|
||||
path: "/forwarder",
|
||||
component: defineAsyncComponent(() => import("./components/forwarder/ForwarderPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "micron-editor",
|
||||
path: "/micron-editor",
|
||||
component: defineAsyncComponent(() => import("./components/micron-editor/MicronEditorPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "profile.icon",
|
||||
path: "/profile/icon",
|
||||
|
||||
@@ -3,4 +3,4 @@ Auto-generated helper so Python tooling and the Electron build
|
||||
share the same version string.
|
||||
"""
|
||||
|
||||
__version__ = '3.1.0'
|
||||
__version__ = "3.3.1"
|
||||
|
||||
5
misc/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# External Dependencies
|
||||
|
||||
LXMF
|
||||
LXST
|
||||
RNS
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reticulum-meshchatx",
|
||||
"version": "3.1.0",
|
||||
"version": "3.3.1",
|
||||
"description": "A simple mesh network communications app powered by the Reticulum Network Stack",
|
||||
"homepage": "https://git.quad4.io/RNS-Things/MeshChatX",
|
||||
"author": "Sudo-Ivan",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "reticulum-meshchatx"
|
||||
version = "3.1.0"
|
||||
version = "3.3.0"
|
||||
description = "A simple mesh network communications app powered by the Reticulum Network Stack"
|
||||
authors = [
|
||||
{name = "Sudo-Ivan"}
|
||||
|
||||
BIN
screenshots/archive-view.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
screenshots/archives.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
screenshots/calling-end.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
screenshots/calling.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
screenshots/identities.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
screenshots/network-visualiser.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
screenshots/network-visualiser2.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
screenshots/phone.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
screenshots/ringtone.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 488 KiB |
BIN
screenshots/tools.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
screenshots/voicemail.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
@@ -14,3 +14,15 @@ if TARGET.exists():
|
||||
shutil.rmtree(TARGET)
|
||||
|
||||
TARGET.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy built assets from root public/ to meshchatx/public/
|
||||
SOURCE = Path("public")
|
||||
if SOURCE.exists():
|
||||
print(f"Copying assets from {SOURCE} to {TARGET}...")
|
||||
for item in SOURCE.iterdir():
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, TARGET / item.name, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(item, TARGET / item.name)
|
||||
else:
|
||||
print(f"Warning: Source directory {SOURCE} not found!")
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Update project version references to stay aligned with the Electron build.
|
||||
|
||||
Reads `package.json`, writes the same version into `src/version.py`, and
|
||||
updates the `[tool.poetry] version` field inside `pyproject.toml`. Run this
|
||||
before any Python packaging commands so the wheel version matches the
|
||||
Electron artifacts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PACKAGE_JSON = ROOT / "package.json"
|
||||
VERSION_PY = ROOT / "meshchatx" / "src" / "version.py"
|
||||
PYPROJECT_TOML = ROOT / "pyproject.toml"
|
||||
|
||||
|
||||
def read_package_version() -> str:
|
||||
with PACKAGE_JSON.open() as handle:
|
||||
return json.load(handle)["version"]
|
||||
|
||||
|
||||
def write_version_module(version: str) -> None:
|
||||
content = (
|
||||
'"""\n'
|
||||
"Auto-generated helper so Python tooling and the Electron build\n"
|
||||
"share the same version string.\n"
|
||||
'"""\n\n'
|
||||
f"__version__ = {version!r}\n"
|
||||
)
|
||||
if VERSION_PY.exists() and VERSION_PY.read_text() == content:
|
||||
return
|
||||
VERSION_PY.write_text(content)
|
||||
|
||||
|
||||
def update_poetry_version(version: str) -> None:
|
||||
if not PYPROJECT_TOML.exists():
|
||||
return
|
||||
content = PYPROJECT_TOML.read_text()
|
||||
|
||||
def replacer(match):
|
||||
return f"{match.group(1)}{version}{match.group(2)}"
|
||||
|
||||
new_content, replaced = re.subn(
|
||||
r'(?m)^(version\s*=\s*")[^"]*(")',
|
||||
replacer,
|
||||
content,
|
||||
count=1,
|
||||
)
|
||||
if replaced == 0:
|
||||
msg = "failed to update version in pyproject.toml"
|
||||
raise RuntimeError(msg)
|
||||
if new_content != content:
|
||||
PYPROJECT_TOML.write_text(new_content)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
version = read_package_version()
|
||||
write_version_module(version)
|
||||
update_poetry_version(version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||