73 Commits

Author SHA1 Message Date
1f9632b396 chore(release): bump version to 3.3.1 and update README with additional screenshots
All checks were successful
Build and Release / Build and Release (push) Successful in 7m22s
CI / lint (push) Successful in 9m28s
CI / build-frontend (push) Successful in 9m34s
2026-01-02 10:28:15 -06:00
6ecd46dcec fix(MicronEditorPage): disable and enable eslint rule for v-html usage
All checks were successful
CI / build-frontend (push) Successful in 9m33s
CI / lint (push) Successful in 9m35s
2026-01-02 09:40:02 -06:00
0b5c8e4e68 refactor(CallPage): cleanup 2026-01-02 09:39:48 -06:00
42e7c2cf3b chore(ci): refactor CI workflow for improved readability and maintainability 2026-01-02 09:39:33 -06:00
e23c5abdd9 fix(version): standardize version string quotes in version.py 2026-01-02 09:39:26 -06:00
fea9389a14 chore(ci): update build workflow to include Windows support and improve release asset handling
Some checks failed
CI / lint (push) Failing after 4m54s
CI / build-frontend (push) Successful in 9m32s
2026-01-02 09:31:16 -06:00
20c0e10767 chore(Taskfile): add tasks for setting up Python environment and linting 2026-01-02 09:31:11 -06:00
7618300619 chore(ci): add CI workflow for linting and building frontend 2026-01-02 09:31:08 -06:00
98092d3c77 chore(cx_setup): update include_files logic to conditionally add directories 2026-01-02 09:23:16 -06:00
2d8e050d61 chore(build): remove sync versions step from workflow 2026-01-02 09:23:10 -06:00
dc6f0cae29 chore(Taskfile): simplify install task and remove sync-version task 2026-01-02 09:23:01 -06:00
2f00c39aba chore(sync_version): remove sync_version.py script as it is no longer needed 2026-01-02 09:20:18 -06:00
92204ba16a docs(README): update 2026-01-02 09:18:19 -06:00
821add9d46 chore(pyproject): bump version from 3.2.0 to 3.3.0
All checks were successful
Build and Release / Build and Release (push) Successful in 2m16s
2026-01-02 01:32:34 -06:00
174a38eb91 docs(README): remove overview section and associated screenshot 2026-01-02 01:30:05 -06:00
227ed0fd44 docs(README): add screenshots for telephony, networking, and tools sections 2026-01-02 01:29:35 -06:00
0dbe463eb5 Upload files to "/" 2026-01-02 07:26:38 +00:00
31bb72448e Upload files to "screenshots" 2026-01-02 07:26:01 +00:00
a10ffd3102 Upload files to "screenshots" 2026-01-02 07:25:38 +00:00
9205006949 fix(README): update video source 2026-01-02 01:24:16 -06:00
b15e9bb7e1 Upload files to "showcase" 2026-01-02 07:22:46 +00:00
72e52b989d docs(README): add credits section 2026-01-02 01:21:50 -06:00
e179ee734c refactor(MicronEditorPage): update preview pane styling to always use dark mode and remove theme observer for cleaner code 2026-01-02 01:20:09 -06:00
331c00fe70 feat(message_handler): add filter_unread option to get_conversations method for improved message retrieval 2026-01-02 01:20:05 -06:00
8c0f4573fd feat(telephony): enhance call handling with DND and contacts-only filters, add voicemail greeting recording endpoints, and improve notification management 2026-01-02 01:20:00 -06:00
3be1c30ff6 refactor(flatpak): streamline flatpak.json formatting for improved readability 2026-01-02 01:19:50 -06:00
b18a538ced feat(taskfile): rename 'develop' task to 'dev', add 'fix' task for linting and formatting issues 2026-01-02 01:19:44 -06:00
705b7e88f7 docs(README): add video demonstration, clarify LXMF Telemetry support, and update Micron Editor status 2026-01-02 01:19:20 -06:00
a465408798 feat(router): add route for Micron Editor component 2026-01-02 01:16:03 -06:00
6ef13ded78 feat(locales): add Micron Editor translations for German, English, and Russian 2026-01-02 01:15:58 -06:00
7746a2db98 feat(ui): enhance sidebar functionality with collapsible feature, improve styling, and add hash copy functionality across components 2026-01-02 01:15:52 -06:00
ba61fab06a feat(flatpak): update pnpm installation process to use local npm prefix and set PATH 2026-01-02 00:16:06 -06:00
87a56d08b8 feat(flatpak): add task to check for required Flatpak SDK and dependencies before building 2026-01-01 23:57:28 -06:00
2b86ea98df feat(flatpak): add Flatpak manifest for Reticulum MeshChatX including build options and runtime configuration 2026-01-01 23:57:10 -06:00
316aa4e556 feat(flatpak): add script for building and packaging Reticulum MeshChatX with Node.js and Electron dependencies 2026-01-01 23:56:52 -06:00
29b12ac940 feat(locales): add 'allow calls from contacts only' string to German, English, and Russian translations 2026-01-01 23:45:08 -06:00
4221f13ba1 feat(flatpak): add tasks for building, installing, and running Flatpak package 2026-01-01 23:44:52 -06:00
18bcce4f4b docs(README): update project description, enhance feature list, and clarify migration warnings 2026-01-01 23:44:14 -06:00
bce2237a1b feat(call): enhance call handling with DND and contacts-only settings, add greeting recording API, and improve notification management 2026-01-01 23:37:16 -06:00
223dac4708 feat(notifications): implement notification system with support for missed calls, viewing status, and fetching unread notifications 2026-01-01 23:37:04 -06:00
54700c0dee feat(database): increment schema version to 20 and add notifications table with relevant indices 2026-01-01 23:36:52 -06:00
d8af1836cd feat(config): add configuration option to allow telephone calls from contacts only 2026-01-01 23:36:44 -06:00
113ececd7a feat(voicemail): implement greeting recording functionality with stabilization delay and improved handling of active calls 2026-01-01 23:36:34 -06:00
05961f160a style(LxmfUserIcon): update icon container styles to use rounded-full for improved aesthetics 2026-01-01 23:36:25 -06:00
825fefdeb1 refactor(notification): streamline notification handling by updating API endpoints, improving unread count logic, and enhancing notification click behavior 2026-01-01 23:36:13 -06:00
2db5c88c8d feat(call): add toggles for Do Not Disturb and contacts-only call settings, enhance voicemail greeting recording functionality 2026-01-01 23:36:02 -06:00
1d52056a2d feat(profile): redesign ProfileIconPage with improved layout, color selection, and icon management features 2026-01-01 23:35:56 -06:00
5ca7308d66 feat(dependencies): add pycodec2 wheel file for enhanced audio codec support in Android application 2026-01-01 23:35:36 -06:00
5ad8003d81 style(meshchat): format code for improved readability by adjusting line breaks in ReticulumMeshChat and CallPage components 2026-01-01 22:47:37 -06:00
d0d204a7d3 feat(workflow): add Android build workflow for APK generation and release asset management 2026-01-01 22:46:43 -06:00
0bc9deffed feat(readme): add external dependencies section listing LXMF, LXST, and RNS 2026-01-01 22:46:15 -06:00
1097a1e5e7 fix(readme): update local WebView access from HTTP to HTTPS for improved security 2026-01-01 22:46:08 -06:00
eafa345ce7 feat(task): add new environment variables for Android build paths and update task commands to use them 2026-01-01 22:46:01 -06:00
e59220e5f9 feat(build): add pycodec2 wheel installation to Chaquopy dependencies in build.gradle 2026-01-01 22:45:54 -06:00
d579a201b3 refactor(meshchat_wrapper): improve code readability by formatting argument list and adding spacing 2026-01-01 22:45:43 -06:00
3e09d7bc44 feat(meshchat): enhance LXMF announce handling by checking for existing announces and recalling identity for hash calculation 2026-01-01 22:45:33 -06:00
802a1a6217 refactor(call): rename 'discovery' tab to 'phonebook' and update search placeholder in CallPage component 2026-01-01 22:45:22 -06:00
5b56f449ef style: format code for improved readability in multiple files, including config_manager, ringtone_manager, voicemail_manager, contacts, ringtones, and telemetry 2026-01-01 22:45:06 -06:00
ba2ba39524 refactor(meshchat): improve code readability by formatting long lines and enhancing structure in ReticulumMeshChat class 2026-01-01 22:44:57 -06:00
93839f0476 feat(map): implement export cancellation functionality in MapManager class 2026-01-01 22:44:44 -06:00
2840ddfa3e fix(call): update active tab from 'discovery' to 'phonebook' in CallPage component 2026-01-01 22:44:35 -06:00
e848306d6d feat(map): add cancel export button and functionality to MapPage component 2026-01-01 22:44:30 -06:00
6dda5b8c26 feat(call): add discovery tab with search functionality and phonebook button in CallPage component 2026-01-01 22:44:24 -06:00
1f1697e53f chore(build): comment out Docker build job in workflow to disable automatic Docker image builds 2026-01-01 22:40:32 -06:00
b73c96b069 feat(locales): add identities section and related translations in German, English, and Russian; update existing translations for consistency 2026-01-01 22:40:24 -06:00
c5deab0767 chore: bump version to 3.2.0 in package.json, pyproject.toml, and version.py
Some checks failed
Build and Release / Build and Release (push) Successful in 2m24s
Build and Release / build_docker (push) Failing after 9m26s
2026-01-01 22:28:28 -06:00
9d8af704b8 fix(meshchat): return display name directly from database entry in ReticulumMeshChat 2026-01-01 22:27:07 -06:00
c10fef9723 fix(voicemail): improve greeting generation and playback error handling in VoicemailManager 2026-01-01 22:26:54 -06:00
62926140df fix(nomadnetwork): adjust layout of favourite card in sidebar for better responsiveness 2026-01-01 22:26:44 -06:00
fc1e5cc9b6 chore(build): refactor build workflow to improve readability, remove unnecessary frontend preparation step, and exclude .sha256 files from checksum generation 2026-01-01 22:17:13 -06:00
d2c2d7a02b chore(android): update Gradle dependencies, adjust Python build path, and enhance AndroidManifest with drawable resources 2026-01-01 21:57:05 -06:00
7555d95d32 chore: update .gitignore and .dockerignore to include additional Android build artifacts (*.apk, *.aab) and new Python directories 2026-01-01 21:56:54 -06:00
78572a6090 feat(android): enhance android-prepare task to copy and vendor dependencies for MeshChatX 2026-01-01 21:56:48 -06:00
68 changed files with 3133 additions and 786 deletions

View File

@@ -11,6 +11,8 @@ electron/
android/
scripts/
Makefile
*.apk
*.aab
# Build artifacts and cache
build/

View 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

View File

@@ -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
View 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
View File

@@ -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
View File

@@ -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
![Phone](screenshots/phone.png)
### Active Call
![Calling](screenshots/calling.png)
### Call Ended
![Call Ended](screenshots/calling-end.png)
### Voicemail
![Voicemail](screenshots/voicemail.png)
### Ringtone Settings
![Ringtone](screenshots/ringtone.png)
</details>
<details>
<summary>Networking & Visualization</summary>
### Network Visualiser
![Network Visualiser](screenshots/network-visualiser.png)
![Network Visualiser 2](screenshots/network-visualiser2.png)
</details>
<details>
<summary>Page Archives</summary>
### Archives Browser
![Archives](screenshots/archives.png)
### Viewing Archived Page
![Archive View](screenshots/archive-view.png)
</details>
<details>
<summary>Tools & Identities</summary>
### Tools
![Tools](screenshots/tools.png)
### Identity Management
![Identities](screenshots/identities.png)
</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)

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}
}

View File

Binary file not shown.

View 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"

View File

@@ -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();

View File

@@ -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

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
}
}

View File

@@ -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
View 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
View 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": "."
}
]
}
]
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -63,4 +63,3 @@ class ContactsDAO:
"SELECT * FROM contacts WHERE remote_identity_hash = ?",
(remote_identity_hash,),
)

View File

@@ -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

View File

@@ -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,))

View File

@@ -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(
"""

View File

@@ -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()

View File

@@ -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"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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);

View File

@@ -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: {

View File

@@ -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"
>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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>&lt;{{ selectedPeer.destination_hash }}&gt;</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);
},

View File

@@ -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>

View File

@@ -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"

View File

@@ -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;
}
},
},
};

View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "Не удалось создать приветствие",

View File

@@ -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",

View File

@@ -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
View File

@@ -0,0 +1,5 @@
# External Dependencies
LXMF
LXST
RNS

View File

@@ -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",

View File

@@ -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"}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
screenshots/archives.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

BIN
screenshots/calling-end.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
screenshots/calling.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
screenshots/identities.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

BIN
screenshots/phone.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

BIN
screenshots/ringtone.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

BIN
screenshots/tools.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

BIN
screenshots/voicemail.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -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!")

View File

@@ -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()

View File

Binary file not shown.