Compare commits

...

302 Commits

Author SHA1 Message Date
bdf4eee267 chore: bump version to 2.50.0 in pyproject.toml and related files 2025-12-05 23:38:55 -06:00
d8683c3191 chore: update package-lock.json for version 2.50.0 and add peer dependencies 2025-12-05 23:38:45 -06:00
fb3096d3fa chore: bump version to 2.50.0 in package.json 2025-12-05 23:38:40 -06:00
d97676ad27 refactor: replace inline artifact renaming with script call
- Updated the Makefile to utilize `rename_legacy_artifacts.sh` for renaming legacy artifacts in the `build-appimage-legacy` and `build-exe-legacy` targets, enhancing maintainability and clarity.
2025-12-05 23:19:43 -06:00
4200e43618 feat: add script for renaming legacy artifacts
- Introduced `rename_legacy_artifacts.sh` to automate the renaming of legacy artifacts with a '-legacy' suffix for better differentiation and maintainability.
2025-12-05 23:19:38 -06:00
c5ae53bf55 chore: streamline legacy artifact renaming in GitHub Actions workflow
- Replaced inline script for renaming legacy artifacts with a dedicated script call to `rename_legacy_artifacts.sh` for improved maintainability and clarity.
2025-12-05 23:19:31 -06:00
bf8c22c31a chore: add dependency review workflow for pull requests
- Introduced a new GitHub Actions workflow for dependency review on pull requests to the master branch.
- Configured permissions for reading contents and writing pull requests.
- Included steps for checking out the repository and running the dependency review action with a summary comment in PRs.
2025-12-05 23:15:44 -06:00
9a9022ffb0 chore: update Python and Node.js versions in GitHub Actions workflow
- Upgraded Python version to 3.13 for Windows, macOS, and Linux builds.
- Updated Node.js version to 22 for Windows and Linux builds, while reverting to Node.js 18 for legacy builds on Windows and Linux.
2025-12-05 23:11:54 -06:00
0443734ee3 chore: add legacy build support for Electron in Makefile
- Introduced new targets for legacy Electron builds, including 'electron-legacy', 'build-appimage-legacy', and 'build-exe-legacy'.
- Updated artifact naming to include '-legacy' suffix for better differentiation of legacy builds.
- Set default legacy Electron version for compatibility.
2025-12-05 22:56:28 -06:00
6efac94f58 chore: update GitHub Actions workflow for multi-platform builds
- Added support for legacy builds with specific Electron versions for Windows and Linux.
- Updated artifact naming conventions to include '-legacy' suffix for legacy builds.
- Introduced conditional steps for setting Electron versions and renaming artifacts based on build variant.
2025-12-05 22:56:22 -06:00
9e1a8ce180 Merge pull request #22 from Sudo-Ivan/universal-mac-build
Update build configuration for macOS and update artifact naming.
2025-12-05 22:43:57 -06:00
f2ab1ad067 fix: use wildcard path to fix the build for mac 2025-12-05 22:37:55 -06:00
365531be9b fix: simplify x64ArchFiles entry in package.json
- Changed x64ArchFiles from an array to a string for the macOS build path, streamlining the configuration.
2025-12-05 22:33:30 -06:00
96f4fc8735 chore: add x64 architecture files for macOS in package.json
- Included new x64ArchFiles entry in package.json to specify the path for macOS x64 architecture builds.
2025-12-05 22:12:13 -06:00
d69a3e8522 chore: update artifact naming for macOS in package.json
- Modified the artifactName format to specify 'mac' in the naming convention for clarity and consistency in builds.
2025-12-05 22:02:03 -06:00
c95d2fd71c chore: update GitHub Actions workflow for artifact handling and distribution scripts
- Changed artifact upload and download actions to specific commit versions for stability.
- Introduced new distribution scripts for various platforms in the build configuration.
- Updated artifact naming patterns for macOS DMG files to ensure consistency.
2025-12-05 22:01:59 -06:00
a74a6869ea chore: enhance build configuration for macOS and update artifact naming
- Added new distribution scripts for macOS ARM64 and universal builds in package.json.
- Updated macOS target configuration to support universal architecture.
- Modified GitHub Actions workflow to use macOS 14 and adjusted artifact naming for macOS DMG files.
2025-12-01 12:37:44 -06:00
d8419990b1 Merge pull request #21 from Sudo-Ivan/codebase-improvements
Migrate to Poetry packaging and restructure codebase
2025-12-01 12:24:24 -06:00
085385a182 chore: update version string formatting in version.py for consistency 2025-12-01 12:22:18 -06:00
f8b0dd18c5 refactor: clean up code and improve variable naming
- Removed unnecessary blank lines in cx_setup.py.
- Reformatted conditional checks in meshchat.py for better readability.
- Updated callback variable names in NomadnetDownloader for clarity.
- Adjusted version string formatting in version.py for consistency.
- Reordered import statements in prepare_frontend_dir.py for better organization.
2025-12-01 12:18:01 -06:00
3231afb84d fix: improve websocket error logging in ReticulumMeshChat
- Updated error logging in websocket handling to use f-strings for better readability and consistency.
2025-12-01 12:00:00 -06:00
3848613a41 fix: update Python command in README for packaging instructions
- Changed `python` to `python3` in the packaging section to ensure compatibility with Python 3 environments.
2025-12-01 11:54:09 -06:00
284517bdfa chore: add security note to prevent path traversal in get_file_path function 2025-12-01 11:53:13 -06:00
5fc13dc61a Update 2025-12-01 11:50:25 -06:00
f989295773 refactor: update Tailwind CSS configuration for frontend structure
- Introduced a variable for the frontend root path to streamline content paths.
- Updated content paths to reflect the new directory structure for Tailwind CSS.
2025-12-01 11:50:11 -06:00
deepsource-autofix[bot]
d06ede8c5e Migrate to Poetry packaging and restructure codebase
Resolved issues in meshchatx/meshchat.py with DeepSource Autofix
2025-12-01 17:36:17 +00:00
a0047ea8fb refactor: update Docker integration and build process
- Modified docker-compose.yml to allow overriding the image with the MESHCHAT_IMAGE environment variable.
- Enhanced Makefile with new build-docker and run-docker targets for streamlined Docker image creation and execution.
- Updated README.md to reflect changes in Docker build and run commands, providing clearer instructions for users.
2025-12-01 11:30:07 -06:00
c98131f76b refactor: frontend preparation script with error handling
- Added a check to ensure the script is run from the project root by verifying the existence of pyproject.toml.
- Implemented a safeguard against removing the TARGET directory if it is a symlink, raising an appropriate error message.
2025-12-01 11:06:10 -06:00
9b4b8fdfeb grammar corrections 2025-12-01 11:05:55 -06:00
48a0d8697e Remove sync_version.py from poetry include in pyproject.toml 2025-12-01 11:05:38 -06:00
5627ae1640 refactor: test_wheel.sh to dynamically find wheel files
- Updated script to search for wheel files using a pattern.
- Added error handling for cases with no matches or multiple matches.
- Improved output to indicate the found wheel file.
2025-12-01 11:05:32 -06:00
94d91c4934 Update dependencies in pyproject.toml and regenerate poetry.lock 2025-12-01 11:03:28 -06:00
ac839df357 Fix https://github.com/Sudo-Ivan/reticulum-meshchatX/pull/21#discussion_r2575731068 2025-12-01 11:02:45 -06:00
cfad1ddc5f Update 2025-12-01 00:07:30 -06:00
398ab570df update 2025-11-30 23:54:15 -06:00
50bc2cbfc8 Enhance GitHub Actions workflow with conditional execution
- Added conditional checks to ensure build steps only run when necessary.
- Streamlined the installation and build processes for NodeJS and Python based on the new build input logic.
- Improved clarity and efficiency in the workflow by consolidating conditions for Linux-specific tasks.
2025-11-30 23:39:43 -06:00
fe3a01c3c6 Refactor GitHub Actions workflow for conditional builds
- Removed redundant conditional checks for build jobs.
- Introduced a new step to determine if the build should run based on event type and input parameters.
- Updated job definitions to utilize the new build input logic for Windows, macOS, and Linux builds.
2025-11-30 23:39:29 -06:00
0b0a39ea86 Refactor Docker setup and frontend structure
- Updated Dockerfile to copy frontend files to meshchatx/public directory.
- Modified .dockerignore to include meshchatx/public.
- Added a new script to prepare the frontend directory.
- Adjusted Vite configuration to output to the new public directory.
- Updated GitHub Actions workflow to reflect changes in build process and artifact handling.
2025-11-30 23:34:45 -06:00
2e001006c9 Update .gitignore to include 'public/' directory 2025-11-30 23:25:59 -06:00
0beaaaf4b1 Add cx_setup.py for building the ReticulumMeshChatX application
- Introduced a new setup script using cx_Freeze to facilitate building the application.
- Updated version.py to maintain consistency in version string formatting.
- Modified build-backend.js to use poetry for executing the build process.
2025-11-30 23:25:54 -06:00
84f887df90 codebase restructure and organization. 2025-11-30 23:16:57 -06:00
80cf812e54 update 2025-11-30 22:49:46 -06:00
19854e59da Refactor: Adjust formatting and structure in database.py and meshchat.py
- Improved readability by restructuring function arguments and parameters across multiple files.
- Enhanced consistency in the formatting of method signatures and exception handling.
- Minor adjustments to comments for clarity and alignment with code style.
2025-11-30 22:38:07 -06:00
ba47e16b75 Rename package from reticulum-meshchat to reticulum-meshchatx in package-lock.json 2025-11-30 21:30:32 -06:00
578e80023f remove 2025-11-30 21:29:04 -06:00
b7dcee4c06 Update 2025-11-30 21:28:59 -06:00
e44ec59b6e Rename reticulum-meshchat service to reticulum-meshchatx and update image reference in docker-compose.yml 2025-11-30 21:28:46 -06:00
45379e6df1 update version and name 2025-11-30 21:28:39 -06:00
308f1f6459 update 2025-11-30 21:28:31 -06:00
424ff116d1 Merge pull request #20 from Sudo-Ivan/deepsource-autofix-29fa619a
refactor: change methods not using its bound instance to staticmethods
2025-11-30 21:23:46 -06:00
deepsource-autofix[bot]
73f677d319 refactor: change methods not using its bound instance to staticmethods
The method doesn't use its bound instance. Decorate this method with `@staticmethod` decorator, so that Python does not have to instantiate a bound method for every instance of this class thereby saving memory and computation. Read more about staticmethods [here](https://docs.python.org/3/library/functions.html#staticmethod).
2025-12-01 03:22:30 +00:00
4770c21499 update to add manual trigger 2025-11-30 21:18:49 -06:00
720bef90c7 remove old workflow 2025-11-30 21:18:42 -06:00
1c98a231fd Refactor ReticulumMeshChat methods to static
- Updated several instance methods in ReticulumMeshChat to static methods for improved clarity and usability.
- Adjusted method calls to reflect the new static context, enhancing code organization.
2025-11-30 21:17:09 -06:00
f6a1be5e80 Replace backend build script in package.json with a Node.js script for improved compatibility and maintainability. Added new build-backend.js script to handle the backend build process using Python. 2025-11-30 21:16:49 -06:00
dea21b8515 Update README 2025-11-30 21:03:27 -06:00
deepsource-io[bot]
c14619e3e3 ci: add .deepsource.toml 2025-12-01 03:01:23 +00:00
bc20f85cbf Update README 2025-11-30 21:00:11 -06:00
8ec7acd57e Refactor error handling and improve code clarity
- Updated exception handling across multiple files to specify Exception type, enhancing clarity and maintainability.
- Renamed several route handler functions in meshchat.py for better readability and consistency.
- Added noqa comments to suppress linting warnings for specific lines in database.py and other files.
2025-11-30 21:00:04 -06:00
59e76de4cc update docker-compose 2025-11-30 20:59:54 -06:00
cc30e6abc1 code cleanup 2025-11-30 20:51:30 -06:00
6dffe70e9b update UI/UX 2025-11-30 20:44:15 -06:00
c054d16f08 Add destination blocking feature in AudioCallManager
- Modified AudioCallManager to accept a callback for checking if a destination is blocked.
- Implemented logic in AudioCallReceiver to reject incoming calls from blocked sources based on the provided callback.
- Enhanced error handling for remote identity retrieval during call connection.
2025-11-30 20:42:32 -06:00
1dbd9a5697 update Makefile 2025-11-30 20:42:23 -06:00
65d6656f47 Refactor executable path resolution in Electron main process
- Updated the executable name to include 'X' for compatibility with setup.py.
- Enhanced the logic to determine the executable path by checking multiple possible locations for both packaged and development modes.
- Added error handling to log and display a message if the executable cannot be found.
2025-11-30 20:42:15 -06:00
a3cb84fa06 Implement spam and blocking features in database and API
- Incremented database version to 6 and added 'is_spam' column to LxmfMessage model.
- Introduced BlockedDestination and SpamKeyword models for managing blocked destinations and spam keywords.
- Added API endpoints for managing blocked destinations and spam keywords, including create, read, and delete operations.
- Enhanced message handling to mark messages as spam based on keywords and block messages from blocked sources.
2025-11-30 20:41:48 -06:00
5fcc86d65a Update dependencies and enhance package configuration
- Upgraded Electron from version 35.7.5 to 39.2.4.
- Updated baseline-browser-mapping and electron-to-chromium to their latest versions.
- Improved package description and author information in package.json.
- Changed build-backend script to use virtual environment Python.
- Enabled ASAR packaging and specified unpacking rules for build files.
2025-11-30 20:41:36 -06:00
253872eb57 update 2025-11-30 20:41:19 -06:00
609a7ede6c update Makefil 2025-11-30 20:25:09 -06:00
49b9bd7782 Update README 2025-11-30 20:15:41 -06:00
c83b90f4f8 Enhance Network Visualiser UI and functionality
- Redesigned control panel for better accessibility and aesthetics.
- Added loading state for update button and improved tooltip styles.
- Introduced conversation fetching and icon generation for user nodes.
- Updated node and edge styling for improved visibility and user experience.
- Enhanced sidebar search functionality to display dynamic placeholder text.
2025-11-30 20:13:41 -06:00
d48a6d9620 Rnode flasher updates 2025-11-30 19:59:56 -06:00
ef80e7a7c4 UI/UX Updates 2025-11-30 19:59:44 -06:00
5967cd827f update ui/ux 2025-11-30 19:38:17 -06:00
52558a7167 update rnode flasher tool page 2025-11-30 19:38:01 -06:00
24194d7e98 adjust scrollbars 2025-11-30 19:18:18 -06:00
273ecfbbbf Update 2025-11-30 19:15:11 -06:00
c65cb04da9 Overhual entire codebase part 1
- Big UI/UX changes
- Improved Config parser
- Some minor improvements and changes
2025-11-30 19:09:08 -06:00
919d191e61 Add download cancellation feature for nomadnet files and pages
- Implemented tracking for active downloads with unique IDs.
- Added functionality to cancel ongoing downloads from the client side.
- Updated UI to include cancel buttons for file and page downloads.
- Enhanced download success, failure, and progress handling to include download IDs for better tracking.
2025-11-28 13:42:25 -06:00
5374a62e96 Update package-lock.json to version 2.32.3, reflecting the version change from 2.32.2. 2025-11-25 12:22:19 -06:00
2e0dfe8700 Update package.json to version 2.32.3 2025-11-25 12:19:52 -06:00
2f30be1490 remove workflow 2025-11-25 12:19:45 -06:00
79aa2bbaa5 Fix Interface error 2025-11-25 12:18:43 -06:00
c92a86015c Update package.json and package-lock.json to version 2.32.2, including dependency updates for Vue and Vuetify packages. 2025-11-25 12:05:36 -06:00
432845195d Update requirements.txt: bump rns version from 1.0.3 to 1.0.4 2025-11-25 12:04:26 -06:00
50a1bf6b3f Fix setup.py: Make build relocatable and portable by adding path replacement and zipping packages 2025-11-25 12:04:05 -06:00
8c39fdf190 Update build.yml: Change Python version from 3.13 to 3.12 2025-11-25 12:03:48 -06:00
9e352e5058 fix prop node app data parsing 2025-11-21 11:51:14 -06:00
0543bd6044 Update package-lock.json and package.json to version 2.32.1, including dependency updates and new package versions. 2025-11-21 11:28:31 -06:00
17d25b2e0a Update README.md: Simplify content and update features for Reticulum MeshChatX fork 2025-11-21 11:26:27 -06:00
1a4b99b201 add 2025-11-21 11:25:47 -06:00
9115f6ecfa Update requirements.txt: bump lxmf to 0.9.3 and rns to 1.0.3 2025-11-21 11:25:40 -06:00
918fcb051c 2.32.0 2025-11-11 08:24:49 -06:00
acbe3597d6 Fix Dockerfile: Add linux-headers and python3-dev to build dependencies 2025-11-11 08:19:51 -06:00
3566c6b2da Add system resource tracking and download speed estimation to ReticulumMeshChat
- Integrated psutil for memory and network statistics.
- Enhanced app info API to include memory usage, network stats, and download statistics.
- Implemented download speed tracking for files in NomadNetworkPage.
- Added utility functions for formatting numbers and bytes per second.
- Updated frontend components to display new statistics in real-time.
2025-11-11 08:04:07 -06:00
8b044f6dab Update requirements.txt: replace rns version with 1.0.2 and add psutil 7.1.3 2025-11-11 07:50:55 -06:00
4c4b963aef Add .pyc files to .gitignore to prevent Python bytecode from being tracked 2025-11-11 07:50:37 -06:00
38ac972960 update 2025-11-11 07:40:01 -06:00
becd3aa15d Add GitHub Actions workflows for Bearer checks 2025-11-11 07:33:26 -06:00
ac907308c0 update 2025-11-11 07:29:17 -06:00
fa2fe6a15d 2.31.0 2025-11-11 07:24:00 -06:00
927255f44c 2.30.0 2025-11-11 07:23:32 -06:00
0318cb7e4a 2.3.0 2025-11-11 07:23:15 -06:00
442ac41841 Update Node.js and Python versions in GitHub Actions workflow: bump Node.js from 18 to 22 and Python from 3.11 to 3.13 2025-11-11 07:16:33 -06:00
40f286621d Update dependencies in requirements.txt: bump lxmf from 0.8.0 to 0.9.2 and rns from 1.0.0 to 1.0.1 2025-11-09 00:14:22 -06:00
9944e9bd63 Merge pull request #16 from Sudo-Ivan/dependabot/npm_and_yarn/cross-spawn-7.0.6
Bump cross-spawn from 7.0.3 to 7.0.6
2025-11-08 12:02:03 -06:00
dependabot[bot]
ec0b5a0924 Bump cross-spawn from 7.0.3 to 7.0.6
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-version: 7.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-08 18:01:36 +00:00
bae4e96d2a Merge pull request #15 from Sudo-Ivan/dependabot/npm_and_yarn/vite-6.4.1
Bump vite from 6.0.5 to 6.4.1
2025-11-08 12:00:29 -06:00
fa15b8f7a3 Merge pull request #4 from Sudo-Ivan/dependabot/npm_and_yarn/tmp-0.2.5
Bump tmp from 0.2.3 to 0.2.5
2025-11-08 12:00:20 -06:00
2ee27557bd Merge pull request #3 from Sudo-Ivan/dependabot/npm_and_yarn/brace-expansion-1.1.12
Bump brace-expansion from 1.1.11 to 1.1.12
2025-11-08 12:00:11 -06:00
8b82a66315 Merge pull request #2 from Sudo-Ivan/dependabot/npm_and_yarn/form-data-4.0.4
Bump form-data from 4.0.0 to 4.0.4
2025-11-08 12:00:01 -06:00
72b0f95cf5 Merge pull request #5 from Sudo-Ivan/dependabot/npm_and_yarn/electron-35.7.5
Bump electron from 30.3.1 to 35.7.5
2025-11-08 11:59:40 -06:00
1f8ec5aa2f Merge pull request #1 from Sudo-Ivan/dependabot/npm_and_yarn/axios-1.12.0
Bump axios from 1.10.0 to 1.12.0
2025-11-08 11:59:27 -06:00
dependabot[bot]
6827ae9c84 Bump vite from 6.0.5 to 6.4.1
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.0.5 to 6.4.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.4.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 00:04:24 +00:00
95ef0935da Remove None values from I2PInterface config before saving 2025-10-01 21:34:11 -05:00
5a5d4b9283 Merge pull request #14 from Sudo-Ivan/i2p-ifac-fix
Fix: Prevent writing None to I2PInterface config
2025-10-01 21:09:22 -05:00
51eaa83301 Fix: Prevent writing None to I2PInterface config 2025-10-01 21:02:54 -05:00
b034b937cd Merge pull request #13 from Sudo-Ivan/actions-full-length-sha
Fix to use step to update repo owner to lower case
2025-10-01 20:01:11 -05:00
69d8bab9e4 Fix to use step to update repo owner to lower case 2025-10-01 20:00:08 -05:00
adac0e5bb1 Merge pull request #12 from Sudo-Ivan/debian-packaging
Add debian packaging support.
2025-10-01 19:51:07 -05:00
12313d34ee Merge pull request #11 from Sudo-Ivan/docker-improvements
Add .dockerignore file and update Dockerfile to use Alpine images for…
2025-10-01 19:50:58 -05:00
55126eaf82 Merge pull request #10 from Sudo-Ivan/actions-full-length-sha
Actions full length sha
2025-10-01 19:50:43 -05:00
aa774f3511 Update manual docker build to use dynamic repo owner 2025-10-01 19:46:27 -05:00
e0e2bbf091 Make owner lowercase 2025-10-01 19:33:20 -05:00
61ada872c0 Add debian packaging support. 2025-10-01 16:07:47 -05:00
dependabot[bot]
3260bffd60 Bump electron from 30.3.1 to 35.7.5
---
updated-dependencies:
- dependency-name: electron
  dependency-version: 35.7.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 19:43:03 +00:00
dependabot[bot]
bbc1eec48e Bump tmp from 0.2.3 to 0.2.5
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.3 to 0.2.5.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.3...v0.2.5)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 19:42:55 +00:00
dependabot[bot]
72266680a2 Bump brace-expansion from 1.1.11 to 1.1.12
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.11 to 1.1.12.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 19:42:55 +00:00
dependabot[bot]
f0336873db Bump form-data from 4.0.0 to 4.0.4
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.0 to 4.0.4.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.0...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 19:42:54 +00:00
dependabot[bot]
d9a39f1ea9 Bump axios from 1.10.0 to 1.12.0
Bumps [axios](https://github.com/axios/axios) from 1.10.0 to 1.12.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.10.0...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-01 19:42:52 +00:00
f0edb4bc8d Add .dockerignore file and update Dockerfile to use Alpine images for Node.js and Python with SHA256 2025-10-01 14:36:24 -05:00
e9d45f257e Update GitHub Actions workflows to use dynamic repository references for Docker image tags and URLs 2025-10-01 14:28:52 -05:00
00e0461a16 Update 2025-10-01 14:16:24 -05:00
c56b982df5 Update GitHub Actions to use full-length SHA hashes with version comments 2025-10-01 14:11:33 -05:00
liamcottle
002360399c add docs 2025-08-01 23:35:32 +12:00
liamcottle
c9f4ef64c1 update peewee to v3.18.1 2025-07-28 22:34:39 +12:00
liamcottle
ffe2cb884d update aiohttp to v3.12.14 2025-07-28 22:11:24 +12:00
liamcottle
d6847d262a add python version to about screen 2025-07-28 21:38:43 +12:00
liamcottle
65df111b87 rework async utils to always use main event loop in threadsafe manner 2025-07-28 19:01:15 +12:00
liamcottle
747236ae8b add try catch for fallback file download parsing, so client can show as unsupported 2025-07-28 17:23:03 +12:00
liamcottle
4e55006084 fix bug where downloading files from cicada forums was not working 2025-07-28 17:21:52 +12:00
liamcottle
dcaffe2594 2.2.1 2025-07-27 21:52:08 +12:00
liamcottle
094f6cb5ec added custom confirm dialog as js confirm in electron on windows causes all text fields to be disabled 2025-07-27 21:45:27 +12:00
liamcottle
0c0f059ec4 2.2.0 2025-07-27 20:18:38 +12:00
liamcottle
9031c1a3d7 add dropdown menu to nomadnetwork favourites list to rename and remove 2025-07-27 20:17:08 +12:00
liamcottle
64adad27f8 limit nomadnetwork announces list to 500 recent nodes 2025-07-25 23:22:56 +12:00
liamcottle
4734e62468 implement favourites system for nomadnetwork nodes 2025-07-25 23:02:05 +12:00
liamcottle
37cc6aa158 add button to identify self to nomad network node 2025-07-25 21:56:02 +12:00
liamcottle
f3bf0abd84 2.1.0 2025-07-18 21:45:35 +12:00
liamcottle
90445467e1 remove todos 2025-07-18 20:34:26 +12:00
liamcottle
51bdd35f01 fix downloading files from nomadnet by handling buffered reader responses 2025-07-18 20:29:10 +12:00
liamcottle
817d5b5e59 don't use await in websocket handler as it blocks all other requests 2025-07-18 19:47:47 +12:00
liamcottle
a094a741a8 don't use await in websocket handler as it blocks all other requests 2025-07-18 19:37:59 +12:00
liamcottle
24acbaf223 update axios to v1.10.0 2025-07-18 19:16:57 +12:00
Liam Cottle
0bb171a81b Merge pull request #82 from Amlor/fix-airtime-limit-description
Fix Airtime Limit fields placeholders
2025-07-18 18:24:37 +12:00
Liam Cottle
b5a54dd120 Merge pull request #89 from kujeger/wayland_flag
add `ozone-platform-hint=auto` to known flags
2025-07-18 16:42:14 +12:00
liamcottle
86cfddce52 update micron-parser to v1.0.2 2025-07-18 16:21:56 +12:00
liamcottle
97071c7edb update lxmf to v0.8.0 2025-07-18 16:19:28 +12:00
liamcottle
a58f73357a update rns to v1.0.0 2025-07-18 16:16:36 +12:00
liamcottle
6b3639dcd2 2.0.0 2025-07-13 19:28:29 +12:00
liamcottle
47a84fc110 update rns to v0.9.6 2025-07-13 18:20:39 +12:00
Nikolai Vincent Vaags
588780d632 add ozone-platform-hint=auto to known flags
This allows electron to run natively under wayland
2025-07-09 11:22:31 +02:00
Viacheslav Komarov
5b783399f8 Fix long window placeholder to say minutes 2025-06-07 11:48:54 +04:00
liamcottle
df533fb1bf update lxmf to v0.7.1 2025-05-24 17:00:16 +12:00
liamcottle
e757a2f022 update lxmf to v0.7.0 2025-05-16 02:46:35 +12:00
liamcottle
ce56c205c6 1.22.0 2025-05-11 21:42:06 +12:00
liamcottle
66b619c398 single line 2025-05-11 21:04:26 +12:00
liamcottle
458a387517 update rns to v0.9.5 2025-05-11 21:02:35 +12:00
Liam Cottle
e97352713d Merge pull request #81 from stephen304/ignore-known-flatpak-flags
filter out known flags that should not be passed to python. fixes #61
2025-05-11 20:59:13 +12:00
Amlor
07a41215be Fix Airtime Limit fields placeholders 2025-04-20 13:31:21 +04:00
Stephen Smith
e9a9e9f831 filter out known flags that should not be passed to python. fixes #61 2025-04-18 11:52:32 -04:00
liamcottle
b8d388fa56 1.21.0 2025-03-15 12:19:10 +13:00
liamcottle
d7080c8ca1 migrate to data attributes for micron parser links 2025-03-15 12:18:30 +13:00
liamcottle
7c20529d62 migrate to using micron-parser from npm 2025-03-15 11:17:34 +13:00
liamcottle
c6eeab97e6 update rns to v0.9.3 2025-03-15 10:52:02 +13:00
liamcottle
10c85cdba0 update lxmf to v0.6.3 2025-03-15 10:47:04 +13:00
liamcottle
9ea98eb0f0 toggle page source in place rather than opening in a new tab 2025-02-09 16:59:08 +13:00
liamcottle
2662f96c8b add button to view source of a node page 2025-02-09 16:19:44 +13:00
liamcottle
59deac6d07 allow passing --headless to compiled electron binary to avoid launching gui 2025-02-08 22:38:47 +13:00
liamcottle
9d60707515 1.20.0 2025-02-08 13:16:02 +13:00
liamcottle
6f321741d7 update rnode flasher 2025-02-08 13:14:48 +13:00
liamcottle
eaf1b75c54 update lxmf to v0.6.2 2025-02-08 13:11:38 +13:00
liamcottle
c59ed015ce update websockets to v14.2 2025-02-08 13:10:24 +13:00
liamcottle
d13b395a2c simplify config for webocket client interface 2025-02-08 12:37:13 +13:00
liamcottle
59c185354b remove todos 2025-02-08 11:57:47 +13:00
liamcottle
9e7d0cdfeb add ping pong to make sure websocket connection doesn't go stale 2025-02-08 11:53:10 +13:00
liamcottle
e6ff5097c0 update logging 2025-02-07 20:10:13 +13:00
liamcottle
ee08a5619c time.sleep uses seconds not millis 2025-02-07 19:49:20 +13:00
liamcottle
c0bb0763a1 fix tx rx stats for web socket server and don't tx and rx when offline or detached 2025-02-07 19:39:24 +13:00
liamcottle
b6e41b3027 remove packet logs 2025-02-07 17:46:45 +13:00
liamcottle
030a1e64a9 always show clients count for interfaces that provide a count 2025-02-07 17:31:25 +13:00
liamcottle
5802671e0d allow setting target protocol type to ws or wss 2025-02-07 17:28:22 +13:00
liamcottle
03d7b669ae show connected clients count for websocket server interface 2025-02-07 17:17:46 +13:00
liamcottle
a81c6787c7 refactor websocket interfaces to use threading and implement detach 2025-02-07 17:00:37 +13:00
liamcottle
a500b58d05 fix missing object values 2025-02-07 14:51:02 +13:00
liamcottle
94179f9779 add todos for detaching 2025-02-07 14:51:02 +13:00
liamcottle
93b6104aef initial implementation of a WebsocketClientInterface and a WebsocketServerInterface for RNS 2025-02-07 14:51:02 +13:00
liamcottle
10bef61a90 use short interface name to find interface stats 2025-02-07 12:54:49 +13:00
liamcottle
0f31c9f8c0 show network name in interfaces list if ifac is enabled 2025-02-03 13:57:41 +13:00
liamcottle
2c518d1b31 fix for calling async functions in sync callbacks from different threads 2025-02-03 13:13:11 +13:00
Liam Cottle
176aed98ff Merge pull request #60 from RFnexus/interfaces-update
Add additional interfaces and interface options
2025-02-03 12:49:44 +13:00
liamcottle
e1ae122297 remove spaces so config format is the same as normal file saving 2025-02-03 01:25:27 +13:00
liamcottle
f6b1c65faa use built in rns config parser for parsing interface config files 2025-02-03 01:23:26 +13:00
liamcottle
4f497620c8 dont export json dict 2025-02-03 01:20:53 +13:00
liamcottle
4d816ae87c fix validation 2025-02-03 01:07:18 +13:00
liamcottle
df8e98366b remove existing sub interfaces when saving an rnode multi interface 2025-02-03 00:13:00 +13:00
liamcottle
54b1d56107 make for loop more readable 2025-02-02 23:58:58 +13:00
liamcottle
ba118f7a9c allow vport 0 2025-02-02 23:56:55 +13:00
liamcottle
e48c26042c always show interface mode setting even if transport is disabled 2025-02-02 23:19:49 +13:00
liamcottle
d95878c659 allow removing custom select settings 2025-02-02 23:16:53 +13:00
liamcottle
734eaeed1b refactor updating of interface settings to allow removing values when saving an existing interface 2025-02-02 23:06:06 +13:00
liamcottle
33e4888737 prevent crash caused by interface settings being set to none 2025-02-02 20:13:52 +13:00
liamcottle
408a62dffe slight adjustments 2025-02-02 20:01:27 +13:00
liamcottle
43a5a907c0 check if null 2025-02-02 18:31:18 +13:00
liamcottle
620c147dbd if interface enable is a boolean, check it as a string 2025-02-02 18:30:43 +13:00
liamcottle
4555de5836 add button to reload comports 2025-02-02 18:21:04 +13:00
liamcottle
842dbeb0b4 make naming consistent and remove unused functions 2025-02-02 18:17:23 +13:00
liamcottle
9d2f3eebc8 refactor to reusable form sub label component 2025-02-02 18:13:45 +13:00
liamcottle
b21e3fc026 add link to docs for interface modes 2025-02-02 18:06:08 +13:00
liamcottle
abd70ae606 refactor to reusable form label component 2025-02-02 18:00:41 +13:00
liamcottle
1e2d4387e7 move ifac subtitle inside of collapsible section 2025-02-02 17:34:10 +13:00
liamcottle
d4b5b99045 add e.g to ui for example values 2025-02-02 17:26:10 +13:00
liamcottle
ce52532522 ui adjustments for rnode interface 2025-02-02 17:21:01 +13:00
liamcottle
6c43c2cc4f revert so interfaces page can scroll 2025-02-02 17:02:43 +13:00
liamcottle
c5e4776dc1 tidy ui for on air rnode bitrate and link budget 2025-02-02 17:00:37 +13:00
liamcottle
dabd6c4a37 ui adjustments 2025-02-02 16:35:57 +13:00
liamcottle
dacd2ea3f2 remove unused component 2025-02-02 16:13:54 +13:00
liamcottle
9741cdcd60 adjust rnode subinterfaces ui 2025-02-02 16:12:23 +13:00
liamcottle
f87a360d5c move optional tcp server interface and udp interface settings to own section 2025-02-02 15:49:29 +13:00
liamcottle
9b62f60e18 simplify ui for ip2 interface peers 2025-02-02 15:35:22 +13:00
liamcottle
019ba93d80 move optional rnode interface settings to own section 2025-02-02 15:22:08 +13:00
liamcottle
01562aff75 move optional rnode interface settings to own section 2025-02-02 02:33:05 +13:00
liamcottle
e2b844f2c2 move shared interface settings to own common interface settings section 2025-02-02 02:24:12 +13:00
liamcottle
c555d8f15b move optional tcp client interface settings to own section 2025-02-02 02:18:57 +13:00
liamcottle
0dc3dc955f move optional auto interface settings to own section 2025-02-02 02:07:18 +13:00
liamcottle
812ff6b887 fix styles 2025-02-02 01:54:44 +13:00
liamcottle
3a13442bb9 collapse ifac grid on small screens 2025-02-02 01:43:32 +13:00
liamcottle
d7375081f3 move ifac settings to its own card section 2025-02-02 01:38:00 +13:00
liamcottle
68ebe4a1c9 remove comment 2025-02-02 01:01:10 +13:00
liamcottle
8b2520f3fa refactor interface section dropdown to a custom expanding section header component 2025-02-02 00:58:10 +13:00
liamcottle
5e068b7341 initial formatting adjustments 2025-02-02 00:28:54 +13:00
liamcottle
d796722772 fix layout for save interface button 2025-02-01 23:47:40 +13:00
rfnx
adad97e917 Add additional interfaces to AddInterfacePage 2025-02-01 01:09:06 -05:00
liamcottle
59eba2ff64 adding a new line in message composer should add it where the cursor is 2025-01-28 17:54:31 +13:00
liamcottle
1bad77553c use router url params for navigating to lxmf conversation 2025-01-22 00:10:27 +13:00
liamcottle
b215c4ac31 update router url when a new nomadnetwork page is loaded 2025-01-22 00:03:38 +13:00
liamcottle
6af4e53de4 add ability to double click a nomadnetwork node in network visualiser to open the browser 2025-01-21 23:58:43 +13:00
liamcottle
558e4c8b3d use isActive instead of isExactActive to allow url props to still show link as active 2025-01-21 23:32:48 +13:00
liamcottle
7d1681fbf1 auto update router url when navigating through conversations 2025-01-21 23:28:00 +13:00
liamcottle
580c907138 add ability to double click an lxmf.delivery node in network visualiser to open the conversation 2025-01-21 23:19:55 +13:00
liamcottle
4ae83ca980 fix formatting 2025-01-20 21:14:53 +13:00
liamcottle
29c062d701 stop updating message state if message gets cancelled 2025-01-20 16:14:08 +13:00
liamcottle
d4b204029a 1.19.0 2025-01-20 13:50:02 +13:00
liamcottle
6f325d24e7 fix issues with calling async function from different threads that may or may not have an event loop 2025-01-20 13:20:03 +13:00
liamcottle
b5f9403c52 add cancelled icon and set background to red 2025-01-20 12:58:33 +13:00
liamcottle
cf059fab63 add button to cancel messages being sent 2025-01-20 12:50:50 +13:00
liamcottle
a3565ef063 add new lxmf message states 2025-01-20 12:45:01 +13:00
liamcottle
541dd8d4f1 update lxmf to v0.6.0 2025-01-20 12:10:07 +13:00
liamcottle
6a1243f482 update rns to v0.9.1 2025-01-20 12:09:34 +13:00
liamcottle
9b36120faa update lang 2025-01-06 19:05:22 +13:00
liamcottle
ff38d4c239 if user provided an address with an "lxmf@" prefix, lets remove that to get the raw destination hash 2025-01-06 18:00:57 +13:00
liamcottle
c5955295d7 add button to open an lxmf address 2025-01-06 17:59:09 +13:00
liamcottle
5d022888b7 add button to open a nomadnet url without having to click a random node first 2025-01-06 17:47:55 +13:00
liamcottle
6b4bf0e31a ignore lxmf messages if they are telemetry requests from sideband 2025-01-05 23:22:20 +13:00
liamcottle
48e56e5285 move transport mode setting to the top 2025-01-02 17:16:58 +13:00
liamcottle
4b6978f7cc add setting to enable and disable transport mode 2025-01-02 17:13:37 +13:00
liamcottle
d3e8c2de9a 1.18.0 2025-01-02 02:20:03 +13:00
liamcottle
282f08edb1 tidy html 2025-01-02 02:09:09 +13:00
liamcottle
629e8d47fb ui improvements for interfaces page 2025-01-02 02:03:25 +13:00
liamcottle
3f73beff2e show port 2025-01-02 01:43:24 +13:00
liamcottle
c55a02ffdc get rid of confusing coding rate prefix 2025-01-02 01:41:09 +13:00
liamcottle
c26d27d01c ui improvements 2025-01-02 01:39:30 +13:00
liamcottle
6d233b759e show info about interfaces being imported 2025-01-02 01:18:40 +13:00
liamcottle
1306593efc export interfaces as a .txt for ease of editing and avoiding issues with weird interface names 2025-01-02 01:08:28 +13:00
liamcottle
8a85a730ab dark mode fixes 2025-01-02 01:05:27 +13:00
liamcottle
e490782d41 dismiss modal when clicking outside of it 2025-01-02 00:48:55 +13:00
liamcottle
7e63c1e752 increase max height 2025-01-02 00:43:14 +13:00
liamcottle
64562c2dc8 ui improvements 2025-01-02 00:41:36 +13:00
liamcottle
b0e7e1d425 adjust ui and tell user what files can be imported 2025-01-02 00:31:53 +13:00
liamcottle
ddf144688e add enable and disable button to interface dropdown menu 2025-01-02 00:21:28 +13:00
liamcottle
ed8ac77ecc add dropdown menu to interfaces 2025-01-02 00:18:32 +13:00
liamcottle
b19ee171eb add button to export single interface 2025-01-02 00:05:51 +13:00
liamcottle
fabb6d5ca3 refactor importing interfaces to use interface parser and allow importing all key value pairs 2025-01-01 23:22:21 +13:00
liamcottle
0b6b390388 refactor interface parser to its own class 2025-01-01 22:04:58 +13:00
liamcottle
82c67bb71c refactor downloading file 2025-01-01 20:55:56 +13:00
liamcottle
372e61ed7c refactor importing interfaces preview 2025-01-01 20:55:10 +13:00
liamcottle
9815decc99 refactor exporting interfaces 2025-01-01 20:30:56 +13:00
liamcottle
65dfd6c540 send json body instead of multipart 2025-01-01 20:26:03 +13:00
liamcottle
de049aead5 rename route 2025-01-01 20:04:03 +13:00
Liam Cottle
99b225e484 Merge pull request #35 from Sudo-Ivan/interface-import-export
Interface Import/Export
2025-01-01 17:35:15 +13:00
liamcottle
de1df07a46 ensure modal can scroll vertically if too high for screen size 2025-01-01 17:23:34 +13:00
liamcottle
e0585d8bcf ui adjustments 2025-01-01 17:19:05 +13:00
liamcottle
12c3310943 move checkbox to right side and allow clicking full container to toggle selection 2025-01-01 16:56:59 +13:00
liamcottle
9ff82c2623 show interface type under interface name 2025-01-01 16:37:48 +13:00
liamcottle
d767c5c002 refactor and ui adjustments 2025-01-01 16:34:25 +13:00
liamcottle
b12aa387bd refactor importing interfaces to its own modal component 2025-01-01 16:07:04 +13:00
liamcottle
80db27da07 tighten spacing 2025-01-01 15:44:26 +13:00
liamcottle
f802eab630 fix vertical alignment 2025-01-01 15:43:01 +13:00
liamcottle
a0d3f88b03 show import button first 2025-01-01 15:37:59 +13:00
liamcottle
3b47d2a521 migrate address 2024-12-31 16:08:09 +13:00
Sudo-Ivan
a49deea8cd accept all file types for import 2024-12-30 20:04:44 -06:00
Sudo-Ivan
b6f8df01f8 align comment 2024-12-30 19:42:30 -06:00
152 changed files with 17297 additions and 25291 deletions

10
.deepsource.toml Normal file
View File

@@ -0,0 +1,10 @@
version = 1
[[analyzers]]
name = "python"
[analyzers.meta]
runtime_version = "3.x.x"
[[analyzers]]
name = "docker"

77
.dockerignore Normal file
View File

@@ -0,0 +1,77 @@
# Documentation
README.md
LICENSE
donate.md
screenshots/
docs/
# Development files
.github/
electron/
scripts/
Makefile
# Build artifacts and cache
build/
dist/
public/
meshchatx/public/
node_modules/
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
*.egg
python-dist/
# Virtual environments
env/
venv/
ENV/
env.bak/
venv.bak/
.venv/
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git/
.gitignore
# Docker files
Dockerfile*
docker-compose*.yml
.dockerignore
# Local storage and runtime data
storage/
testing/
telemetry_test_lxmf/
# Logs
*.log
# Temporary files
*.tmp
*.temp
# Environment variables
.env
.env.local
.env.*.local

20
.github/workflows/bearer-pr.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: Bearer PR Check
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
security-events: write
jobs:
rule_check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Bearer
uses: bearer/bearer-action@828eeb928ce2f4a7ca5ed57fb8b59508cb8c79bc # v2
with:
diff: true

View File

@@ -4,154 +4,327 @@ on:
push:
tags:
- "*"
workflow_dispatch:
inputs:
build_windows:
description: 'Build Windows'
required: false
default: 'true'
type: boolean
build_mac:
description: 'Build macOS'
required: false
default: 'true'
type: boolean
build_linux:
description: 'Build Linux'
required: false
default: 'true'
type: boolean
build_docker:
description: 'Build Docker'
required: false
default: 'true'
type: boolean
permissions:
contents: read
jobs:
build_windows:
runs-on: windows-latest
permissions:
contents: write
steps:
- name: Clone Repo
uses: actions/checkout@v1
- name: Install NodeJS
uses: actions/setup-node@v1
with:
node-version: 18
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Python Deps
run: pip install -r requirements.txt
- name: Install NodeJS Deps
run: npm install
- name: Build Electron App
run: npm run dist
- name: Create Release
id: create_release
uses: ncipollo/release-action@v1
with:
draft: true
allowUpdates: true
replacesArtifacts: true
omitDraftDuringUpdate: true
omitNameDuringUpdate: true
artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
build_mac:
runs-on: macos-13
permissions:
contents: write
steps:
- name: Clone Repo
uses: actions/checkout@v1
- name: Install NodeJS
uses: actions/setup-node@v1
with:
node-version: 18
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Python Deps
run: pip install -r requirements.txt
- name: Install NodeJS Deps
run: npm install
- name: Build Electron App
run: npm run dist
- name: Create Release
id: create_release
uses: ncipollo/release-action@v1
with:
draft: true
allowUpdates: true
replacesArtifacts: true
omitDraftDuringUpdate: true
omitNameDuringUpdate: true
artifacts: "dist/*-mac.dmg"
build_linux:
build_frontend:
runs-on: ubuntu-latest
permissions:
contents: write
contents: read
steps:
- name: Clone Repo
uses: actions/checkout@v1
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
- name: Install NodeJS
uses: actions/setup-node@v1
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
with:
node-version: 18
node-version: 22
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
python-version: "3.12"
- name: Install Python Deps
run: pip install -r requirements.txt
- name: Sync versions
run: python scripts/sync_version.py
- name: Install NodeJS Deps
run: npm install
- name: Build Electron App
run: npm run dist
- name: Build Frontend
run: npm run build-frontend
- name: Upload frontend artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: frontend-build
path: meshchatx/public
if-no-files-found: error
build_desktop:
name: Build Desktop (${{ matrix.name }})
needs: build_frontend
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: windows
os: windows-latest
node: 22
python: "3.13"
release_artifacts: "dist/*-win-installer.exe,dist/*-win-portable.exe"
build_input: build_windows
dist_script: dist-prebuilt
variant: standard
electron_version: "39.2.4"
- name: mac
os: macos-14
node: 22
python: "3.13"
release_artifacts: "dist/*-mac-*.dmg"
build_input: build_mac
dist_script: dist:mac-universal
variant: standard
electron_version: "39.2.4"
- name: linux
os: ubuntu-latest
node: 22
python: "3.13"
release_artifacts: "dist/*-linux.AppImage,dist/*-linux.deb,python-dist/*.whl"
build_input: build_linux
dist_script: dist-prebuilt
variant: standard
electron_version: "39.2.4"
- name: windows-legacy
os: windows-latest
node: 18
python: "3.11"
release_artifacts: "dist/*-win-installer*.exe,dist/*-win-portable*.exe"
build_input: build_windows
dist_script: dist-prebuilt
variant: legacy
electron_version: "30.0.8"
- name: linux-legacy
os: ubuntu-latest
node: 18
python: "3.11"
release_artifacts: "dist/*-linux*.AppImage,dist/*-linux*.deb,python-dist/*.whl"
build_input: build_linux
dist_script: dist-prebuilt
variant: legacy
electron_version: "30.0.8"
permissions:
contents: write
steps:
- name: Clone Repo
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1
- name: Set legacy Electron version
if: |
matrix.variant == 'legacy' &&
(github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true))
shell: bash
run: |
node -e "const fs=require('fs');const pkg=require('./package.json');pkg.devDependencies.electron='${{ matrix.electron_version }}';fs.writeFileSync('package.json', JSON.stringify(pkg,null,2));"
if [ -f package-lock.json ]; then rm package-lock.json; fi
- name: Install NodeJS
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
uses: actions/setup-node@f1f314fca9dfce2769ece7d933488f076716723e # v1
with:
node-version: ${{ matrix.node }}
- name: Install Python
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ matrix.python }}
- name: Install Poetry
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
run: python -m pip install --upgrade pip poetry
- name: Sync versions
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
run: python scripts/sync_version.py
- name: Install Python Deps
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
run: python -m poetry install
- name: Install NodeJS Deps
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
run: npm install
- name: Prepare frontend directory
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
run: python scripts/prepare_frontend_dir.py
- name: Download frontend artifact
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: frontend-build
path: meshchatx/public
- name: Install patchelf
if: |
startsWith(matrix.name, 'linux') &&
(github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true))
run: sudo apt-get update && sudo apt-get install -y patchelf
- name: Build Python wheel
if: |
startsWith(matrix.name, 'linux') &&
(github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true))
run: |
python -m poetry build -f wheel
mkdir -p python-dist
mv dist/*.whl python-dist/
rm -rf dist
- name: Build Electron App (Universal)
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
run: npm run ${{ matrix.dist_script }}
- name: Rename artifacts for legacy build
if: |
matrix.variant == 'legacy' &&
(github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true))
run: ./scripts/rename_legacy_artifacts.sh
- name: Upload build artifacts
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs[matrix.build_input] == true)
uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.name }}
path: |
dist/*-win-installer*.exe
dist/*-win-portable*.exe
dist/*-mac-*.dmg
dist/*-linux*.AppImage
dist/*-linux*.deb
python-dist/*.whl
if-no-files-found: ignore
create_release:
name: Create Release
needs: build_desktop
runs-on: ubuntu-latest
if: github.event_name == 'push'
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: artifacts
- name: Display structure of downloaded files
run: ls -R artifacts
- name: Prepare release assets
run: |
mkdir -p release-assets
find artifacts -type f \( -name "*.exe" -o -name "*.dmg" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.whl" \) -exec cp {} release-assets/ \;
ls -lh release-assets/
- name: Generate SHA256 checksums
run: |
cd release-assets
echo "## SHA256 Checksums" > release-body.md
echo "" >> release-body.md
for file in *.exe *.dmg *.AppImage *.deb *.whl; do
if [ -f "$file" ]; then
sha256sum "$file" | tee "${file}.sha256"
echo "\`$(cat "${file}.sha256")\`" >> release-body.md
fi
done
echo "" >> release-body.md
echo "Individual \`.sha256\` files are included for each artifact." >> release-body.md
cat release-body.md
echo ""
echo "Generated .sha256 files:"
ls -1 *.sha256 2>/dev/null || echo "No .sha256 files found"
- name: Create Release
id: create_release
uses: ncipollo/release-action@v1
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
with:
draft: true
allowUpdates: true
replacesArtifacts: true
omitDraftDuringUpdate: true
omitNameDuringUpdate: true
artifacts: "dist/*-linux.AppImage"
artifacts: "release-assets/*"
bodyFile: "release-assets/release-body.md"
build_docker:
runs-on: ubuntu-latest
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.build_docker == 'true')
permissions:
packages: write
contents: read
steps:
- name: Clone Repo
uses: actions/checkout@v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Set lowercase repository owner
run: echo "REPO_OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: Log in to the GitHub Container registry
uses: docker/login-action@v3
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker images
uses: docker/build-push-action@v5
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/liamcottle/reticulum-meshchat:latest
ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }}
labels: |
org.opencontainers.image.title=Reticulum MeshChat
org.opencontainers.image.description=Docker image for Reticulum MeshChat
org.opencontainers.image.url=https://github.com/liamcottle/reticulum-meshchat/pkgs/container/reticulum-meshchat/
tags: >-
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:latest,
ghcr.io/${{ env.REPO_OWNER_LC }}/reticulum-meshchatx:${{ github.ref_name }}
labels: >-
org.opencontainers.image.title=Reticulum MeshChatX,
org.opencontainers.image.description=Docker image for Reticulum MeshChatX,
org.opencontainers.image.url=https://github.com/${{ github.repository }}/pkgs/container/reticulum-meshchatx/

22
.github/workflows/dependency-review.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: 'Dependency review'
on:
pull_request:
branches: [ "master" ]
permissions:
contents: read
pull-requests: write
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: 'Checkout repository'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: 'Dependency Review'
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4
with:
comment-summary-in-pr: always

View File

@@ -1,42 +0,0 @@
name: Temporary manual trigger for Docker build
on:
workflow_dispatch:
jobs:
build_docker:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Clone Repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the GitHub Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/liamcottle/reticulum-meshchat:latest
ghcr.io/liamcottle/reticulum-meshchat:${{ github.ref_name }}
labels: |
org.opencontainers.image.title=Reticulum MeshChat
org.opencontainers.image.description=Docker image for Reticulum MeshChat
org.opencontainers.image.url=https://github.com/liamcottle/reticulum-meshchat/pkgs/container/reticulum-meshchat/

54
.gitignore vendored
View File

@@ -1,11 +1,57 @@
# IDE and editor files
.idea
node_modules
.vscode/
*.swp
*.swo
*~
# build files
# Dependencies
node_modules/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
*.egg
# Virtual environments
venv/
env/
ENV/
env.bak/
venv.bak/
.venv/
# Build files
/build/
/dist/
/public/
/meshchatx/public/
public/
/electron/build/exe/
python-dist/
# local storage
# Local storage and runtime data
storage/
testing/
telemetry_test_lxmf/
# Logs
*.log
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Environment variables
.env
.env.local
.env.*.local

View File

@@ -1,33 +1,42 @@
# Build arguments
ARG NODE_VERSION=20
ARG NODE_ALPINE_SHA256=sha256:6a91081a440be0b57336fbc4ee87f3dab1a2fd6f80cdb355dcf960e13bda3b59
ARG PYTHON_VERSION=3.11
ARG PYTHON_ALPINE_SHA256=sha256:822ceb965f026bc47ee667e50a44309d2d81087780bbbf64f2005521781a3621
# Build the frontend
FROM node:20-bookworm-slim AS build-frontend
FROM node:${NODE_VERSION}-alpine@${NODE_ALPINE_SHA256} AS build-frontend
WORKDIR /src
# Copy required source files
COPY *.json .
COPY *.js .
COPY src/frontend ./src/frontend
COPY package*.json vite.config.js ./
COPY meshchatx ./meshchatx
# Install NodeJS deps, exluding electron
RUN npm install --omit=dev && \
npm run build-frontend
# Main app build
FROM python:3.11-bookworm
FROM python:${PYTHON_VERSION}-alpine@${PYTHON_ALPINE_SHA256}
WORKDIR /app
# Install Python deps
COPY ./requirements.txt .
RUN pip install -r requirements.txt
RUN apk add --no-cache --virtual .build-deps \
gcc \
musl-dev \
linux-headers \
python3-dev && \
pip install -r requirements.txt && \
apk del .build-deps
# Copy prebuilt frontend
COPY --from=build-frontend /src/public public
COPY --from=build-frontend /src/meshchatx/public meshchatx/public
# Copy other required source files
COPY *.py .
COPY src/__init__.py ./src/__init__.py
COPY src/backend ./src/backend
COPY *.json .
COPY meshchatx ./meshchatx
COPY pyproject.toml poetry.lock ./
CMD ["python", "meshchat.py", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]
CMD ["python", "-m", "meshchatx.meshchat", "--host=0.0.0.0", "--reticulum-config-dir=/config/.reticulum", "--storage-dir=/config/.meshchat", "--headless"]

87
Makefile Normal file
View File

@@ -0,0 +1,87 @@
.PHONY: install run develop clean build build-appimage build-exe dist sync-version wheel node_modules python build-docker run-docker electron-legacy build-appimage-legacy build-exe-legacy
PYTHON ?= python
POETRY = $(PYTHON) -m poetry
NPM = npm
LEGACY_ELECTRON_VERSION ?= 30.0.8
DOCKER_COMPOSE_CMD ?= docker compose
DOCKER_COMPOSE_FILE ?= docker-compose.yml
DOCKER_IMAGE ?= reticulum-meshchatx:local
DOCKER_BUILDER ?= meshchatx-builder
DOCKER_PLATFORMS ?= linux/amd64
DOCKER_BUILD_FLAGS ?= --load
DOCKER_BUILD_ARGS ?=
DOCKER_CONTEXT ?= .
DOCKERFILE ?= Dockerfile
install: sync-version node_modules python
node_modules:
$(NPM) install
python:
$(POETRY) install
run: install
$(POETRY) run meshchat
develop: run
build: install
$(NPM) run build
wheel: install
$(POETRY) build -f wheel
$(PYTHON) scripts/move_wheels.py
build-appimage: build
$(NPM) run electron-postinstall
$(NPM) run dist -- --linux AppImage
build-exe: build
$(NPM) run electron-postinstall
$(NPM) run dist -- --win portable
dist: build-appimage
electron-legacy:
$(NPM) install --no-save electron@$(LEGACY_ELECTRON_VERSION)
# Legacy targets intended for manual/local builds; CI uses workflow jobs.
build-appimage-legacy: build electron-legacy
$(NPM) run electron-postinstall
$(NPM) run dist -- --linux AppImage
./scripts/rename_legacy_artifacts.sh
build-exe-legacy: build electron-legacy
$(NPM) run electron-postinstall
$(NPM) run dist -- --win portable
./scripts/rename_legacy_artifacts.sh
clean:
rm -rf node_modules
rm -rf build
rm -rf dist
rm -rf python-dist
rm -rf meshchatx/public
sync-version:
$(PYTHON) scripts/sync_version.py
build-docker:
@if ! docker buildx inspect $(DOCKER_BUILDER) >/dev/null 2>&1; then \
docker buildx create --name $(DOCKER_BUILDER) --use >/dev/null; \
else \
docker buildx use $(DOCKER_BUILDER); \
fi
docker buildx build --builder $(DOCKER_BUILDER) --platform $(DOCKER_PLATFORMS) \
$(DOCKER_BUILD_FLAGS) \
-t $(DOCKER_IMAGE) \
$(DOCKER_BUILD_ARGS) \
-f $(DOCKERFILE) \
$(DOCKER_CONTEXT)
run-docker:
MESHCHAT_IMAGE="$(DOCKER_IMAGE)" \
$(DOCKER_COMPOSE_CMD) -f $(DOCKER_COMPOSE_FILE) up --remove-orphans --pull never reticulum-meshchatx

363
README.md
View File

@@ -1,311 +1,110 @@
<p align="center">
<a href="https://github.com/liamcottle/reticulum-meshchat"><img src="./logo/logo-chat-bubble.png" width="150"></a>
</p>
# Reticulum MeshChatX
<h2 align="center">Reticulum MeshChat</h2>
A heavily customized fork of [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat), any meaningful, stable and tested modifications will be submitted as a PR upstream.
<p align="center">
<a href="https://discord.gg/APQSQZNV7t"><img src="https://img.shields.io/badge/Discord-Liam%20Cottle's%20Discord-%237289DA?style=flat&logo=discord" alt="discord"/></a>
<a href="https://twitter.com/liamcottle"><img src="https://img.shields.io/badge/Twitter-@liamcottle-%231DA1F2?style=flat&logo=twitter" alt="twitter"/></a>
<br/>
<a href="https://ko-fi.com/liamcottle"><img src="https://img.shields.io/badge/Donate%20a%20Coffee-liamcottle-yellow?style=flat&logo=buy-me-a-coffee" alt="donate on ko-fi"/></a>
<a href="./donate.md"><img src="https://img.shields.io/badge/Donate%20Bitcoin-3FPBfiEwioWHFix3kZqe5bdU9F5o8mG8dh-%23FF9900?style=flat&logo=bitcoin" alt="donate bitcoin"/></a>
</p>
## Features of this fork
## What is Reticulum MeshChat?
- [x] Custom UI/UX (actively being improved)
- [x] Ability to set inbound and propagation node stamps.
- [x] Better config parsing.
- [x] Cancel page fetching or file downloads
- [x] Block receiving messages from users.
- [ ] Spam filter (based on keywords)
- [ ] Multi-identity support.
- [ ] Multi-language support
- [ ] Offline Reticulum documentation tool
- [ ] More tools (translate, LoRa calculator, LXMFy bots, etc.)
- [x] Codebase reorganization and cleanup.
- [ ] Tests and proper CI/CD pipeline.
- [ ] RNS hot reload
- [ ] Backup/Import identities, messages and interfaces.
- [ ] Full LXST support.
- [x] Poetry for packaging and dependency management.
- [x] More stats on about page.
- [x] Actions are pinned to full-length SHA hashes.
- [x] Docker images are smaller and use SHA256 hashes for the images.
- [x] Electron improvements (ASAR and security).
- [x] Latest updates for NPM and Python dependencies (bleeding edge)
- [x] Numerous Ruff, Deepsource, CodeQL Advanced and Bearer Linting/SAST fixes.
- [x] Some performance improvements.
A simple mesh network communications app powered by the [Reticulum Network Stack](https://github.com/markqvist/Reticulum).
## Usage
<img src="./screenshots/screenshot.png">
Check [releases](https://github.com/Sudo-Ivan/reticulum-meshchatX/releases) for pre-built binaries or appimages.
## What does it do?
## Building
- It can send and receive messages, files and audio calls with peers;
- Over your local network through Ethernet and WiFi, completely automatically.
- Over the internet by connecting through a server [hosted by yourself](https://reticulum.network/manual/interfaces.html#tcp-server-interface) or [the community](https://reticulum.network/connect.html).
- Over low-powered, license-free, ISM band LoRa Radio, with an [RNode](https://github.com/markqvist/RNode_Firmware).
- ...and via [any other interface](https://reticulum.network/manual/interfaces.html) supported by the Reticulum Network Stack.
- It communicates securely. Messages can only be decrypted by the intended destination.
- It can communicate with any other existing [LXMF](https://github.com/markqvist/lxmf) client, such as [Sideband](https://github.com/markqvist/Sideband/) and [Nomadnet](https://github.com/markqvist/nomadnet).
- It can download files and browse micron pages (decentralised websites) hosted on [Nomad Network](https://github.com/markqvist/nomadnet) nodes.
## Features
- Supports sending and receiving messages between [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat), [Sideband](https://github.com/markqvist/Sideband/) and [Nomadnet](https://github.com/markqvist/nomadnet).
- Supports receiving and saving images and attachments sent from Sideband.
- Supports sending images, voice recordings and file attachments.
- Supports saving inbound and outbound messages to a local database.
- Supports sending an announce to the network.
- Supports setting a custom display name to send in your announce.
- Supports viewing and searching peers discovered from announces.
- Supports auto resending undelivered messages when an announce is received from the recipient.
- Supports sending messages to and syncing messages from [LXMF Propagation Nodes](https://github.com/markqvist/lxmf?tab=readme-ov-file#propagation-nodes).
- Supports running a local LXMF Propagation Node so other users can use your device for message storage and retrieval.
- Support for browsing pages, and downloading files hosted on Nomad Network Nodes.
## Beta Features
- Support for Audio Calls to other [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat) users.
- Audio is encoded with [codec2](https://github.com/drowe67/codec2) to support low bandwidth links.
- Using a microphone requires using the web ui over localhost or https, due to [AudioWorklet](https://developer.mozilla.org/en-US/docs/Web/API/AudioWorklet) secure context.
- I have tested two-way audio calls over LoRa with a single hop. It works well when a [reasonable bitrate](https://unsigned.io/understanding-lora-parameters/) is configured on the RNode.
- Some browsers such as FireFox don't work as expected. Try using a Chromium based browser if running via the command line.
## Download
You can download the latest version for Windows, Mac and Linux from the [releases](https://github.com/liamcottle/reticulum-meshchat/releases) page.
Alternatively, you can download the source and run it manually from a command line.
See the ["How to use it?"](#how-to-use-it) section, further down on how to do this.
## Other Installation Methods
- [Running MeshChat on Docker](./docs/meshchat_on_docker.md)
- [Running MeshChat on a Raspberry Pi](./docs/meshchat_on_raspberry_pi.md)
- [Running MeshChat on Android with Termux](./docs/meshchat_on_android_with_termux.md)
## Getting Started
Once you've downloaded, installed and launched Reticulum MeshChat, there's a few things you need to do in order to start communicating with other people on the network.
1. Create an Identity
2. Configure your Display Name
3. Send an Announce
4. Discover Peers and start sending messages
5. Configuring additional Network Interfaces
**Create an Identity**
On the Reticulum Network, anyone can have any number of Identities. You may opt to use your real name, or you may decide to be completely anonymous. The choice is yours.
A Reticulum Identity is a public/private key-pair. You control the private key used to generate destination addresses, encrypt content and prove receipt of data with unforgeable delivery acknowledgements.
Your public key is shared with the network when you send an announce, and allows others on the network to automatically discover a route to a destination you control.
At this time, Reticulum MeshChat generates a new Identity the first time you launch it. A future update will allow you to create and manage multiple identities.
For now, if you want to change, or reset your identity, you can access the identity file at `~/.reticulum-meshchat/identity`.
**Configure your Display Name**
The next thing you should do, is set a display name. Your display name is what everyone else on the network will see when looking for someone to communicate with from the Peers list.
You can do this in the `My Identity` section in the bottom left corner. Enter a new display name, and then press `Save`.
**Send an Announce**
When using the Reticulum Network, in order to be contactable, you need to send an `Announce`. You can send an announce as often, or as infrequently as you like.
Sending an announce allows other peers on the network to discover the next-hop across the network their packets should take to arrive at a destination that your identity controls.
If you never send an announce, you will be invisible and no one will ever be able to send anything to you.
When you move across the network, and change entrypoints, such as moving from your home WiFi network, to plugging in to an Ethernet port in a local library or even climbing a mountain and using an RNode over LoRa radio, other peers on the network will only know the previous path to your destinations.
To allow them to discover the new path their packets should take to reach you, you should send an announce.
**Discover Peers and start sending messages**
In the Reticulum Network, you can control an unlimited number of destination addresses. One of these can be an [LXMF](https://github.com/markqvist/lxmf) delivery address.
Your Reticulum Identity allows you to have an LXMF address. Think of an LXMF address as your very own, secure, end-to-end encrypted, unspoofable, email address routed over a mesh network.
When someone else on the network announces themselves (more specifically, their LXMF address), they will show up in the Peers tab.
You can click on any of these discovered peers to open a messaging interface. From here, you can send text messages, files and inline images. If they respond, their messages will show up there too.
As well as being able to announce your LXMF address and discover others, Reticulum MeshChat can also discover [Nomad Network](https://github.com/markqvist/nomadnet) nodes hosted by other users. From the Nodes tab, you are free to explore pages and download files they may be publicly sharing on the network.
A future update is planned to allow you to host your own Node and share pages and files with other peers on the network. For now, you could use the official [Nomad Network](https://github.com/markqvist/nomadnet) client to do this.
Remember, in order to connect with other peers or nodes, they must announce on the network. So don't forget to announce if you want to be discovered!
**Configuring additional Network Interfaces**
> TODO: this section is yet to be written. For now, you can check out the [official documentation for configuring interfaces](https://reticulum.network/manual/interfaces.html) in the Reticulum config file. This file is located at `~/.reticulum/config`
## How does it work?
- A python script ([meshchat.py](./meshchat.py)) runs a Reticulum instance and a WebSocket server.
- The web page sends and receives LXMF packets encoded in json via the WebSocket.
- Web Browser -> WebSocket -> Python Reticulum -> (configured interfaces) -> (destination)
- LXMF messages sent and received are saved to a local SQLite database.
## How to use it?
It is recommended that you [download](#download) a standalone application.
If you don't want to, or a release is unavailable for your device, you will need to;
- install [Python 3](https://www.python.org/downloads/)
- install [NodeJS v18+](https://nodejs.org/en)
- clone the source code from this repo
- install all dependencies
- then run `meshchat.py`.
```
# clone repo
git clone https://github.com/liamcottle/reticulum-meshchat
cd reticulum-meshchat
# install nodejs deps
# if you want to build electron binaries, remove "--omit=dev"
# if you're using termux, add "--ignore-scripts" to fix error with esbuild
npm install --omit=dev
# build frontend vue components
npm run build-frontend
# install python deps
pip install -r requirements.txt
# run meshchat
python meshchat.py
```bash
make install # installs Python deps via Poetry and Node deps via npm
make build
```
> NOTE: You should now be able to access the web interface at http://localhost:8000
You can run `make run` or `make develop` (a thin alias) to start the backend + frontend loop locally through `poetry run meshchat`.
For a full list of command line options, you can run;
### Python packaging
```
python meshchat.py --help
The Python build is driven entirely by Poetry now. Run `python3 scripts/sync_version.py` or `make sync-version` before packaging so `pyproject.toml` and `src/version.py` match `package.json`. After that:
```bash
python -m poetry install
make wheel # produces a wheel in python-dist/ that bundles the public assets
```
```
usage: meshchat.py [-h] [--host [HOST]] [--port [PORT]] [--headless] [--identity-file IDENTITY_FILE] [--identity-base64 IDENTITY_BASE64] [--generate-identity-file GENERATE_IDENTITY_FILE] [--generate-identity-base64]
[--reticulum-config-dir RETICULUM_CONFIG_DIR] [--storage-dir STORAGE_DIR]
The wheel includes the frontend `public/` assets, `logo/`, and the CLI entry point, and `python-dist/` keeps the artifact separate from the Electron `dist/` output.
ReticulumMeshChat
### Building in Docker
options:
-h, --help show this help message and exit
--host [HOST] The address the web server should listen on.
--port [PORT] The port the web server should listen on.
--headless Web browser will not automatically launch when this flag is passed.
--identity-file IDENTITY_FILE
Path to a Reticulum Identity file to use as your LXMF address.
--identity-base64 IDENTITY_BASE64
A base64 encoded Reticulum Identity to use as your LXMF address.
--generate-identity-file GENERATE_IDENTITY_FILE
Generates and saves a new Reticulum Identity to the provided file path and then exits.
--generate-identity-base64
Outputs a randomly generated Reticulum Identity as base64 and then exits.
--reticulum-config-dir RETICULUM_CONFIG_DIR
Path to a Reticulum config directory for the RNS stack to use (e.g: ~/.reticulum)
--storage-dir STORAGE_DIR
Path to a directory for storing databases and config files (default: ./storage)
```bash
make build-docker
```
## Using an existing Reticulum Identity
`build-docker` creates `reticulum-meshchatx:local` (or `$(DOCKER_IMAGE)` if you override it) via `docker buildx`. Set `DOCKER_PLATFORMS` to `linux/amd64,linux/arm64` when you need multi-arch images, and adjust `DOCKER_BUILD_FLAGS`/`DOCKER_BUILD_ARGS` to control `--load`/`--push`.
The first time you run this application, a new Reticulum identity is generated and saved to `storage/identity`.
### Running with Docker Compose
If you want to use an existing identity;
- You can overwrite `storage/identity` with another identity file.
- Or, you can pass in a custom identity file path as a command line argument.
To use a custom identity file, provide the `--identity-file` argument followed by the path to your custom identity file.
```
python meshchat.py --identity-file ./custom_identity_file
```bash
make run-docker
```
If you would like to generate a new identity, you can use the [rnid](https://reticulum.network/manual/using.html#the-rnid-utility) utility provided by Reticulum.
`run-docker` feeds the locally-built image into `docker compose -f docker-compose.yml up --remove-orphans --pull never reticulum-meshchatx`. The compose file uses the `MESHCHAT_IMAGE` env var so you can override the target image without editing the YAML (the default still points at `ghcr.io/sudo-ivan/reticulum-meshchatx:latest`). Use `docker compose down` or `Ctrl+C` to stop the container.
```
rnid --generate ./new_identity_file
The Electron build artifacts will still live under `dist/` for releases.
## Python packaging
The backend uses Poetry with `pyproject.toml` for dependency management and packaging. Before building, run `python3 scripts/sync_version.py` (or `make sync-version`) to ensure the generated `src/version.py` reflects the version from `package.json` that the Electron artifacts use. This keeps the CLI release metadata, wheel packages, and other bundles aligned.
### Build artifact locations
Both `poetry build` and `python -m build` generate wheels inside the default `dist/` directory. The `make wheel` shortcut wraps `poetry build -f wheel` and then runs `python scripts/move_wheels.py` to relocate the generated `.whl` files into `python-dist/` (the layout expected by `scripts/test_wheel.sh` and the release automation). Use `make wheel` if you need the artifacts in `python-dist/`; `poetry build` or `python -m build` alone will leave them in `dist/`.
### Building with Poetry
```bash
# Install dependencies
poetry install
# Build the package (wheels land in dist/)
poetry build
# Install locally for testing (consumes dist/)
pip install dist/*.whl
```
If you don't have access to the `rnid` command, you can use the following:
### Building with pip (alternative)
```
python meshchat.py --generate-identity-file ./new_identity_file
If you prefer pip, you can build/install directly:
```bash
# Build the wheel
pip install build
python -m build
# Install locally
pip install .
```
Alternatively, you can provide a base64 encoded private key, like so;
### cx_Freeze (for AppImage/NSIS)
```
python meshchat.py --identity-base64 "GCN6mMhVemdNIK/fw97C1zvU17qjQPFTXRBotVckeGmoOwQIF8VOjXwNNem3CUOJZCQQpJuc/4U94VSsC39Phw=="
```
The `cx_setup.py` script uses cx_Freeze for creating standalone executables (AppImage for Linux, NSIS for Windows). This is separate from the Poetry/pip packaging workflow.
> NOTE: this is a randomly generated identity for example purposes. Do not use it, it has been leaked!
## Build Electron Application
Reticulum MeshChat can be run from source via a command line, as explained above, or as a standalone application.
To run as a standalone application, we need to compile the python script and dependencies to an executable with [cxfreeze](https://github.com/marcelotduarte/cx_Freeze) and then build an [Electron](https://www.electronjs.org/) app which includes a bundled browser that can interact with the compiled python executable.
This allows for the entire application to be run by double clicking a single file without the need for a user to manually install python, nor run any commands in a command line application.
To build a `.exe` when running on Windows or a `.dmg` when running on a Mac, run the following;
```
pip install -r requirements.txt
npm install
npm run dist
```
> Note: cxfreeze only supports building an executable for the current platform. You will need a Mac to build for Mac, and a Windows PC to build for Windows.
Once completed, you should have a `.exe` or a `.dmg` in the `dist` folder.
## Local Development
I normally run the following commands to work on the project locally.
**Install dependencies**
```
pip install -r requirements.txt
npm install
```
**Build and run Electron App**
```
npm run electron
```
**or; Build and run MeshChat Server**
```
npm run build-frontend
python3 meshchat.py --headless
```
I build the vite app everytime without hot reload, since MeshChat expects everything over its own port, not the vite server port. I will attempt to fix this in the future.
## TODO
- [ ] button to forget announces
- [ ] support for managing Reticulum interfaces via the web ui
- [x] AutoInterface
- [x] RNodeInterface
- [x] TCPClientInterface
- [x] TCPServerInterface
- [x] UDPInterface
- [ ] I2PInterface
- [ ] SerialInterface
- [ ] PipeInterface
- [ ] KISSInterface
- [ ] AX25KISSInterface
- [ ] Other Options
- [ ] network_name
- [ ] passphrase
# Notes
**LXMF Router**
- By default, the LXMF router rejects inbound messages larger than 1mb.
- LXMF clients are likely to have [this default limit](https://github.com/markqvist/LXMF/blob/c426c93cc5d63a3dae18ad2264b1299a7ad9e46c/LXMF/LXMRouter.py#L38), and your messages will [fail to send](https://github.com/markqvist/LXMF/blob/c426c93cc5d63a3dae18ad2264b1299a7ad9e46c/LXMF/LXMRouter.py#L1428).
- MeshChat has increased the receive limit to 10mb to allow for larger attachments.
## License
MIT

47
cx_setup.py Normal file
View File

@@ -0,0 +1,47 @@
from pathlib import Path
from cx_Freeze import Executable, setup
from meshchatx.src.version import __version__
ROOT = Path(__file__).resolve().parent
PUBLIC_DIR = ROOT / "meshchatx" / "public"
include_files = [
(str(PUBLIC_DIR), "public"),
("logo", "logo"),
]
setup(
name="ReticulumMeshChatX",
version=__version__,
description="A simple mesh network communications app powered by the Reticulum Network Stack",
executables=[
Executable(
script="meshchatx/meshchat.py",
base=None,
target_name="ReticulumMeshChatX",
shortcut_name="ReticulumMeshChatX",
shortcut_dir="ProgramMenuFolder",
icon="logo/icon.ico",
),
],
options={
"build_exe": {
"packages": [
"RNS",
"RNS.Interfaces",
"LXMF",
],
"include_files": include_files,
"excludes": [
"PIL",
],
"optimize": 2,
"build_exe": "build/exe",
"replace_paths": [
("*", ""),
],
},
},
)

View File

@@ -1,152 +0,0 @@
from datetime import datetime, timezone
from peewee import *
from playhouse.migrate import migrate as migrate_database, SqliteMigrator
latest_version = 5 # increment each time new database migrations are added
database = DatabaseProxy() # use a proxy object, as we will init real db client inside meshchat.py
migrator = SqliteMigrator(database)
# migrates the database
def migrate(current_version):
# migrate to version 2
if current_version < 2:
migrate_database(
migrator.add_column("lxmf_messages", 'delivery_attempts', LxmfMessage.delivery_attempts),
migrator.add_column("lxmf_messages", 'next_delivery_attempt_at', LxmfMessage.next_delivery_attempt_at),
)
# migrate to version 3
if current_version < 3:
migrate_database(
migrator.add_column("lxmf_messages", 'rssi', LxmfMessage.rssi),
migrator.add_column("lxmf_messages", 'snr', LxmfMessage.snr),
migrator.add_column("lxmf_messages", 'quality', LxmfMessage.quality),
)
# migrate to version 4
if current_version < 4:
migrate_database(
migrator.add_column("lxmf_messages", 'method', LxmfMessage.method),
)
# migrate to version 5
if current_version < 5:
migrate_database(
migrator.add_column("announces", 'rssi', Announce.rssi),
migrator.add_column("announces", 'snr', Announce.snr),
migrator.add_column("announces", 'quality', Announce.quality),
)
return latest_version
class BaseModel(Model):
class Meta:
database = database
class Config(BaseModel):
id = BigAutoField()
key = CharField(unique=True)
value = TextField()
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
# define table name
class Meta:
table_name = "config"
class Announce(BaseModel):
id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash that was announced
aspect = TextField(index=True) # aspect is not included in announce, but we want to filter saved announces by aspect
identity_hash = CharField(index=True) # identity hash that announced the destination
identity_public_key = CharField() # base64 encoded public key, incase we want to recreate the identity manually
app_data = TextField(null=True) # base64 encoded app data bytes
rssi = IntegerField(null=True)
snr = FloatField(null=True)
quality = FloatField(null=True)
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
# define table name
class Meta:
table_name = "announces"
class CustomDestinationDisplayName(BaseModel):
id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash
display_name = CharField() # custom display name for the destination hash
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
# define table name
class Meta:
table_name = "custom_destination_display_names"
class LxmfMessage(BaseModel):
id = BigAutoField()
hash = CharField(unique=True) # unique lxmf message hash
source_hash = CharField(index=True)
destination_hash = CharField(index=True)
state = CharField() # state is converted from internal int to a human friendly string
progress = FloatField() # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
is_incoming = BooleanField() # if true, we should ignore state, it's set to draft by default on incoming messages
method = CharField(null=True) # what method is being used to send the message, e.g: direct, propagated
delivery_attempts = IntegerField(default=0) # how many times delivery has been attempted for this message
next_delivery_attempt_at = FloatField(null=True) # timestamp of when the message will attempt delivery again
title = TextField()
content = TextField()
fields = TextField() # json string
timestamp = FloatField() # timestamp of when the message was originally created (before ever being sent)
rssi = IntegerField(null=True)
snr = FloatField(null=True)
quality = FloatField(null=True)
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
# define table name
class Meta:
table_name = "lxmf_messages"
class LxmfConversationReadState(BaseModel):
id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash
last_read_at = DateTimeField()
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
# define table name
class Meta:
table_name = "lxmf_conversation_read_state"
class LxmfUserIcon(BaseModel):
id = BigAutoField()
destination_hash = CharField(unique=True) # unique destination hash
icon_name = CharField() # material design icon name for the destination hash
foreground_colour = CharField() # hex colour to use for foreground (icon colour)
background_colour = CharField() # hex colour to use for background (background colour)
created_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
updated_at = DateTimeField(default=lambda: datetime.now(timezone.utc))
# define table name
class Meta:
table_name = "lxmf_user_icons"

View File

@@ -1,12 +1,12 @@
services:
reticulum-meshchat:
container_name: reticulum-meshchat
image: ghcr.io/liamcottle/reticulum-meshchat:latest
reticulum-meshchatx:
container_name: reticulum-meshchatx
image: ${MESHCHAT_IMAGE:-ghcr.io/sudo-ivan/reticulum-meshchatx:latest}
pull_policy: always
restart: unless-stopped
# Make the meshchat web interface accessible from the host on port 8000
ports:
- 0.0.0.0:8000:8000
- 127.0.0.1:8000:8000
volumes:
- meshchat-config:/config
# Uncomment if you have a USB device connected, such as an RNode

View File

@@ -1,10 +0,0 @@
# Donate
Thank you for considering donating, this helps support my work on this project 😁
## How can I donate?
- Bitcoin: 3FPBfiEwioWHFix3kZqe5bdU9F5o8mG8dh
- Ethereum: 0xc64CFbA5D0BF7664158c5671F64d446395b3bF3D
- Buy me a Coffee: [https://ko-fi.com/liamcottle](https://ko-fi.com/liamcottle)
- Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle)

View File

@@ -22,6 +22,27 @@ ipcMain.handle('alert', async(event, message) => {
});
});
// add support for showing a confirm window via ipc
ipcMain.handle('confirm', async(event, message) => {
// show confirm dialog
const result = await dialog.showMessageBox(mainWindow, {
type: "question",
title: "Confirm",
message: message,
cancelId: 0, // esc key should press cancel button
defaultId: 1, // enter key should press ok button
buttons: [
"Cancel", // 0
"OK", // 1
],
});
// check if user clicked OK
return result.response === 1;
});
// add support for showing a prompt window via ipc
ipcMain.handle('prompt', async(event, message) => {
return await electronPrompt({
@@ -48,6 +69,9 @@ ipcMain.handle('showPathInFolder', (event, path) => {
function log(message) {
// log to stdout of this process
console.log(message);
// make sure main window exists
if(!mainWindow){
return;
@@ -58,9 +82,6 @@ function log(message) {
return;
}
// log to electron console
console.log(message);
// log to web console
mainWindow.webContents.send('log', message);
@@ -98,70 +119,124 @@ function getDefaultReticulumConfigDir() {
app.whenReady().then(async () => {
// create browser window
mainWindow = new BrowserWindow({
width: 1500,
height: 800,
webPreferences: {
// used to inject logging over ipc
preload: path.join(__dirname, 'preload.js'),
},
});
// get arguments passed to application, and remove the provided application path
const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"];
const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg));
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
// open external links in default web browser instead of electron
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if(!shouldLaunchHeadless){
var shouldShowInNewElectronWindow = false;
// create browser window
mainWindow = new BrowserWindow({
width: 1500,
height: 800,
webPreferences: {
// used to inject logging over ipc
preload: path.join(__dirname, 'preload.js'),
// Security: disable node integration in renderer
nodeIntegration: false,
// Security: enable context isolation (default in Electron 12+)
contextIsolation: true,
// Security: enable sandbox for additional protection
sandbox: true,
// Security: disable remote module (deprecated but explicit)
enableRemoteModule: false,
},
});
// we want to open call.html in a new electron window
// but all other target="_blank" links should open in the system web browser
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
if(url.startsWith("http://localhost") && url.includes("/call.html")){
shouldShowInNewElectronWindow = true;
}
// open external links in default web browser instead of electron
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
// we want to open blob urls in a new electron window
else if(url.startsWith("blob:")) {
shouldShowInNewElectronWindow = true;
}
var shouldShowInNewElectronWindow = false;
// open in new electron window
if(shouldShowInNewElectronWindow){
// we want to open call.html in a new electron window
// but all other target="_blank" links should open in the system web browser
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
if(url.startsWith("http://localhost") && url.includes("/call.html")){
shouldShowInNewElectronWindow = true;
}
// we want to open blob urls in a new electron window
else if(url.startsWith("blob:")) {
shouldShowInNewElectronWindow = true;
}
// open in new electron window
if(shouldShowInNewElectronWindow){
return {
action: "allow",
};
}
// fallback to opening any other url in external browser
shell.openExternal(url);
return {
action: "allow",
action: "deny",
};
});
// navigate to loading page
await mainWindow.loadFile(path.join(__dirname, 'loading.html'));
// ask mac users for microphone access for audio calls to work
if(process.platform === "darwin"){
await systemPreferences.askForMediaAccess('microphone');
}
// fallback to opening any other url in external browser
shell.openExternal(url);
return {
action: "deny",
};
});
// navigate to loading page
await mainWindow.loadFile(path.join(__dirname, 'loading.html'));
}
// find path to python/cxfreeze reticulum meshchat executable
const exeName = process.platform === "win32" ? "ReticulumMeshChat.exe" : "ReticulumMeshChat";
var exe = path.join(__dirname, `build/exe/${exeName}`);
// if dist exe doesn't exist, check local build
if(!fs.existsSync(exe)){
exe = path.join(__dirname, '..', `build/exe/${exeName}`);
// Note: setup.py creates ReticulumMeshChatX (with X), not ReticulumMeshChat
const exeName = process.platform === "win32" ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX";
// get app path (handles both development and packaged app)
const appPath = app.getAppPath();
// get resources path (where extraFiles are placed)
const resourcesPath = process.resourcesPath || path.join(appPath, '..', '..');
var exe = null;
// when packaged, extraFiles are placed at resources/app/electron/build/exe
// when packaged with asar, unpacked files are in app.asar.unpacked/ directory
// app.getAppPath() returns the path to app.asar, so unpacked is at the same level
const possiblePaths = [
// packaged app - extraFiles location (resources/app/electron/build/exe)
path.join(resourcesPath, 'app', 'electron', 'build', 'exe', exeName),
// packaged app with asar (unpacked files from asarUnpack)
path.join(appPath, '..', 'app.asar.unpacked', 'build', 'exe', exeName),
// packaged app without asar (relative to app path)
path.join(appPath, 'build', 'exe', exeName),
// development mode (relative to electron directory)
path.join(__dirname, 'build', 'exe', exeName),
// development mode (relative to project root)
path.join(__dirname, '..', 'build', 'exe', exeName),
];
// find the first path that exists
for(const possibleExe of possiblePaths){
if(fs.existsSync(possibleExe)){
exe = possibleExe;
break;
}
}
// ask mac users for microphone access for audio calls to work
if(process.platform === "darwin"){
await systemPreferences.askForMediaAccess('microphone');
// verify executable exists
if(!exe || !fs.existsSync(exe)){
const errorMsg = `Could not find executable: ${exeName}\nChecked paths:\n${possiblePaths.join('\n')}\n\nApp path: ${appPath}\nResources path: ${resourcesPath}`;
log(errorMsg);
if(mainWindow){
await dialog.showMessageBox(mainWindow, {
message: errorMsg,
});
}
app.quit();
return;
}
log(`Found executable at: ${exe}`);
try {
// get arguments passed to application, and remove the provided application path
const userProvidedArguments = process.argv.slice(1);
// arguments we always want to pass in
const requiredArguments = [
'--headless', // reticulum meshchat usually launches default web browser, we don't want this when using electron

View File

@@ -15,6 +15,11 @@ contextBridge.exposeInMainWorld('electron', {
return await ipcRenderer.invoke('alert', message);
},
// show a confirm dialog in electron browser window, this fixes a bug where confirm breaks input fields on windows
confirm: async function(message) {
return await ipcRenderer.invoke('confirm', message);
},
// add support for using "prompt" in electron browser window
prompt: async function(message) {
return await ipcRenderer.invoke('prompt', message);

View File

File diff suppressed because it is too large Load Diff

3
meshchatx/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Reticulum MeshChatX - A mesh network communications app."""
__version__ = "2.50.0"

225
meshchatx/database.py Normal file
View File

@@ -0,0 +1,225 @@
from datetime import UTC, datetime
from peewee import * # noqa: F403
from playhouse.migrate import SqliteMigrator
from playhouse.migrate import migrate as migrate_database
latest_version = 6 # increment each time new database migrations are added
database = (
DatabaseProxy() # noqa: F405
) # use a proxy object, as we will init real db client inside meshchat.py
migrator = SqliteMigrator(database)
# migrates the database
def migrate(current_version):
# migrate to version 2
if current_version < 2:
migrate_database(
migrator.add_column(
"lxmf_messages",
"delivery_attempts",
LxmfMessage.delivery_attempts,
),
migrator.add_column(
"lxmf_messages",
"next_delivery_attempt_at",
LxmfMessage.next_delivery_attempt_at,
),
)
# migrate to version 3
if current_version < 3:
migrate_database(
migrator.add_column("lxmf_messages", "rssi", LxmfMessage.rssi),
migrator.add_column("lxmf_messages", "snr", LxmfMessage.snr),
migrator.add_column("lxmf_messages", "quality", LxmfMessage.quality),
)
# migrate to version 4
if current_version < 4:
migrate_database(
migrator.add_column("lxmf_messages", "method", LxmfMessage.method),
)
# migrate to version 5
if current_version < 5:
migrate_database(
migrator.add_column("announces", "rssi", Announce.rssi),
migrator.add_column("announces", "snr", Announce.snr),
migrator.add_column("announces", "quality", Announce.quality),
)
# migrate to version 6
if current_version < 6:
migrate_database(
migrator.add_column("lxmf_messages", "is_spam", LxmfMessage.is_spam),
)
return latest_version
class BaseModel(Model): # noqa: F405
class Meta:
database = database
class Config(BaseModel):
id = BigAutoField() # noqa: F405
key = CharField(unique=True) # noqa: F405
value = TextField() # noqa: F405
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
# define table name
class Meta:
table_name = "config"
class Announce(BaseModel):
id = BigAutoField() # noqa: F405
destination_hash = CharField( # noqa: F405
unique=True,
) # unique destination hash that was announced
aspect = TextField( # noqa: F405
index=True,
) # aspect is not included in announce, but we want to filter saved announces by aspect
identity_hash = CharField( # noqa: F405
index=True,
) # identity hash that announced the destination
identity_public_key = (
CharField() # noqa: F405
) # base64 encoded public key, incase we want to recreate the identity manually
app_data = TextField(null=True) # noqa: F405 # base64 encoded app data bytes
rssi = IntegerField(null=True) # noqa: F405
snr = FloatField(null=True) # noqa: F405
quality = FloatField(null=True) # noqa: F405
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
# define table name
class Meta:
table_name = "announces"
class CustomDestinationDisplayName(BaseModel):
id = BigAutoField() # noqa: F405
destination_hash = CharField(unique=True) # noqa: F405 # unique destination hash
display_name = CharField() # noqa: F405 # custom display name for the destination hash
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
# define table name
class Meta:
table_name = "custom_destination_display_names"
class FavouriteDestination(BaseModel):
id = BigAutoField() # noqa: F405
destination_hash = CharField(unique=True) # noqa: F405 # unique destination hash
display_name = CharField() # noqa: F405 # custom display name for the destination hash
aspect = CharField() # noqa: F405 # e.g: nomadnetwork.node
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
# define table name
class Meta:
table_name = "favourite_destinations"
class LxmfMessage(BaseModel):
id = BigAutoField() # noqa: F405
hash = CharField(unique=True) # noqa: F405 # unique lxmf message hash
source_hash = CharField(index=True) # noqa: F405
destination_hash = CharField(index=True) # noqa: F405
state = (
CharField() # noqa: F405
) # state is converted from internal int to a human friendly string
progress = FloatField() # noqa: F405 # progress is converted from internal float 0.00-1.00 to float between 0.00/100 (2 decimal places)
is_incoming = BooleanField() # noqa: F405 # if true, we should ignore state, it's set to draft by default on incoming messages
method = CharField( # noqa: F405
null=True,
) # what method is being used to send the message, e.g: direct, propagated
delivery_attempts = IntegerField( # noqa: F405
default=0,
) # how many times delivery has been attempted for this message
next_delivery_attempt_at = FloatField( # noqa: F405
null=True,
) # timestamp of when the message will attempt delivery again
title = TextField() # noqa: F405
content = TextField() # noqa: F405
fields = TextField() # noqa: F405 # json string
timestamp = (
FloatField() # noqa: F405
) # timestamp of when the message was originally created (before ever being sent)
rssi = IntegerField(null=True) # noqa: F405
snr = FloatField(null=True) # noqa: F405
quality = FloatField(null=True) # noqa: F405
is_spam = BooleanField(default=False) # noqa: F405 # if true, message is marked as spam
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
# define table name
class Meta:
table_name = "lxmf_messages"
class LxmfConversationReadState(BaseModel):
id = BigAutoField() # noqa: F405
destination_hash = CharField(unique=True) # noqa: F405 # unique destination hash
last_read_at = DateTimeField() # noqa: F405
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
# define table name
class Meta:
table_name = "lxmf_conversation_read_state"
class LxmfUserIcon(BaseModel):
id = BigAutoField() # noqa: F405
destination_hash = CharField(unique=True) # noqa: F405 # unique destination hash
icon_name = CharField() # noqa: F405 # material design icon name for the destination hash
foreground_colour = CharField() # noqa: F405 # hex colour to use for foreground (icon colour)
background_colour = (
CharField() # noqa: F405
) # hex colour to use for background (background colour)
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
# define table name
class Meta:
table_name = "lxmf_user_icons"
class BlockedDestination(BaseModel):
id = BigAutoField() # noqa: F405
destination_hash = CharField( # noqa: F405
unique=True,
index=True,
) # unique destination hash that is blocked
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
# define table name
class Meta:
table_name = "blocked_destinations"
class SpamKeyword(BaseModel):
id = BigAutoField() # noqa: F405
keyword = CharField( # noqa: F405
unique=True,
index=True,
) # keyword to match against message content
created_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
updated_at = DateTimeField(default=lambda: datetime.now(UTC)) # noqa: F405
# define table name
class Meta:
table_name = "spam_keywords"

5438
meshchatx/meshchat.py Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,6 @@ import sys
# this class forces stream writes to be flushed immediately
class ImmediateFlushingStreamWrapper:
def __init__(self, stream):
self.stream = stream

View File

@@ -0,0 +1 @@
"""Backend utilities shared by the Reticulum MeshChatX CLI."""

View File

@@ -1,16 +1,27 @@
# an announce handler that forwards announces to a provided callback for the provided aspect filter
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
class AnnounceHandler:
def __init__(self, aspect_filter: str, received_announce_callback):
self.aspect_filter = aspect_filter
self.received_announce_callback = received_announce_callback
# we will just pass the received announce back to the provided callback
def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash):
def received_announce(
self,
destination_hash,
announced_identity,
app_data,
announce_packet_hash,
):
try:
# handle received announce
self.received_announce_callback(self.aspect_filter, destination_hash, announced_identity, app_data, announce_packet_hash)
except:
self.received_announce_callback(
self.aspect_filter,
destination_hash,
announced_identity,
app_data,
announce_packet_hash,
)
except Exception:
# ignore failure to handle received announce
pass

View File

@@ -0,0 +1,23 @@
import asyncio
from collections.abc import Coroutine
class AsyncUtils:
# remember main loop
main_loop: asyncio.AbstractEventLoop | None = None
@staticmethod
def set_main_loop(loop: asyncio.AbstractEventLoop):
AsyncUtils.main_loop = loop
# this method allows running the provided async coroutine from within a sync function
# it will run the async function on the main event loop if possible, otherwise it logs a warning
@staticmethod
def run_async(coroutine: Coroutine):
# run provided coroutine on main event loop, ensuring thread safety
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
return
# main event loop not running...
print("WARNING: Main event loop not available. Could not schedule task.")

View File

@@ -1,11 +1,10 @@
import asyncio
import time
from typing import List
import RNS
# todo optionally identity self over link
# todo allowlist/denylist for incoming calls
# TODO optionally identity self over link
# TODO allowlist/denylist for incoming calls
class CallFailedException(Exception):
@@ -13,7 +12,6 @@ class CallFailedException(Exception):
class AudioCall:
def __init__(self, link: RNS.Link, is_outbound: bool):
self.link = link
self.is_outbound = is_outbound
@@ -41,21 +39,25 @@ class AudioCall:
# handle packet received over link
def on_packet(self, message, packet):
# send audio received from call initiator to all audio packet listeners
for audio_packet_listener in self.audio_packet_listeners:
audio_packet_listener(message)
# send an audio packet over the link
def send_audio_packet(self, data):
# do nothing if link is not active
if self.is_active() is False:
return
# drop audio packet if it is too big to send
if len(data) > RNS.Link.MDU:
print("[AudioCall] dropping audio packet " + str(len(data)) + " bytes exceeds the link packet MDU of " + str(RNS.Link.MDU) + " bytes")
print(
"[AudioCall] dropping audio packet "
+ str(len(data))
+ " bytes exceeds the link packet MDU of "
+ str(RNS.Link.MDU)
+ " bytes",
)
return
# send codec2 audio received from call receiver to call initiator over reticulum link
@@ -73,25 +75,26 @@ class AudioCall:
def hangup(self):
print("[AudioCall] hangup")
self.link.teardown()
pass
class AudioCallManager:
def __init__(self, identity: RNS.Identity):
def __init__(self, identity: RNS.Identity, is_destination_blocked_callback=None):
self.identity = identity
self.on_incoming_call_callback = None
self.on_outgoing_call_callback = None
self.is_destination_blocked_callback = is_destination_blocked_callback
self.audio_call_receiver = AudioCallReceiver(manager=self)
# remember audio calls
self.audio_calls: List[AudioCall] = []
self.audio_calls: list[AudioCall] = []
# announces the audio call destination
def announce(self, app_data=None):
self.audio_call_receiver.destination.announce(app_data)
print("[AudioCallManager] announced destination: " + RNS.prettyhexrep(self.audio_call_receiver.destination.hash))
print(
"[AudioCallManager] announced destination: "
+ RNS.prettyhexrep(self.audio_call_receiver.destination.hash),
)
# set the callback for incoming calls
def register_incoming_call_callback(self, callback):
@@ -103,7 +106,6 @@ class AudioCallManager:
# handle incoming calls from audio call receiver
def handle_incoming_call(self, audio_call: AudioCall):
# remember it
self.audio_calls.append(audio_call)
@@ -113,7 +115,6 @@ class AudioCallManager:
# handle outgoing calls
def handle_outgoing_call(self, audio_call: AudioCall):
# remember it
self.audio_calls.append(audio_call)
@@ -142,22 +143,26 @@ class AudioCallManager:
def hangup_all(self):
for audio_call in self.audio_calls:
audio_call.hangup()
return None
# attempts to initiate a call to the provided destination and returns the link hash on success
async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15) -> AudioCall:
async def initiate(
self,
destination_hash: bytes,
timeout_seconds: int = 15,
) -> AudioCall:
# determine when to timeout
timeout_after_seconds = time.time() + timeout_seconds
# check if we have a path to the destination
if not RNS.Transport.has_path(destination_hash):
# we don't have a path, so we need to request it
RNS.Transport.request_path(destination_hash)
# wait until we have a path, or give up after the configured timeout
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after_seconds:
while (
not RNS.Transport.has_path(destination_hash)
and time.time() < timeout_after_seconds
):
await asyncio.sleep(0.1)
# if we still don't have a path, we can't establish a link, so bail out
@@ -171,14 +176,16 @@ class AudioCallManager:
RNS.Destination.OUT,
RNS.Destination.SINGLE,
"call",
"audio"
"audio",
)
# create link
link = RNS.Link(server_destination)
# wait until we have established a link, or give up after the configured timeout
while link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds:
while (
link.status is not RNS.Link.ACTIVE and time.time() < timeout_after_seconds
):
await asyncio.sleep(0.1)
# if we still haven't established a link, bail out
@@ -191,16 +198,14 @@ class AudioCallManager:
# handle new outgoing call
self.handle_outgoing_call(audio_call)
# todo: this can be optional, it's only being sent by default for ui, can be removed
# TODO: this can be optional, it's only being sent by default for ui, can be removed
link.identify(self.identity)
return audio_call
class AudioCallReceiver:
def __init__(self, manager: AudioCallManager):
self.manager = manager
# create destination for receiving audio calls
@@ -224,8 +229,24 @@ class AudioCallReceiver:
# client connected to us, set up an audio call instance
def client_connected(self, link: RNS.Link):
# check if source is blocked
if self.manager.is_destination_blocked_callback is not None:
try:
# try to get remote identity hash
remote_identity = link.get_remote_identity()
if remote_identity is not None:
source_hash = remote_identity.hash.hex()
if self.manager.is_destination_blocked_callback(source_hash):
print(
f"Rejecting audio call from blocked source: {source_hash}",
)
link.teardown()
return
except Exception:
# if we can't get identity yet, we'll check later
pass
# todo: this can be optional, it's only being sent by default for ui, can be removed
# TODO: this can be optional, it's only being sent by default for ui, can be removed
link.identify(self.manager.identity)
# create audio call

View File

@@ -1,10 +1,8 @@
class ColourUtils:
@staticmethod
def hex_colour_to_byte_array(hex_colour):
# remove leading "#"
hex_colour = hex_colour.lstrip('#')
hex_colour = hex_colour.lstrip("#")
# convert the remaining hex string to bytes
return bytes.fromhex(hex_colour)

View File

@@ -0,0 +1,91 @@
import RNS.vendor.configobj
class InterfaceConfigParser:
@staticmethod
def parse(text):
# get lines from provided text
lines = text.splitlines()
stripped_lines = [line.strip() for line in lines]
# ensure [interfaces] section exists
if "[interfaces]" not in stripped_lines:
lines.insert(0, "[interfaces]")
stripped_lines.insert(0, "[interfaces]")
try:
# parse lines as rns config object
config = RNS.vendor.configobj.ConfigObj(lines)
except Exception as e:
print(f"Failed to parse interface config with ConfigObj: {e}")
return InterfaceConfigParser._parse_best_effort(lines)
# get interfaces from config
config_interfaces = config.get("interfaces", {})
if config_interfaces is None:
return []
# process interfaces
interfaces = []
for interface_name in config_interfaces:
# ensure interface has a name
interface_config = config_interfaces[interface_name]
interface_config["name"] = interface_name
interfaces.append(interface_config)
return interfaces
@staticmethod
def _parse_best_effort(lines):
interfaces = []
current_interface_name = None
current_interface = {}
current_sub_name = None
current_sub = None
def commit_sub():
nonlocal current_sub_name, current_sub
if current_sub_name and current_sub is not None:
current_interface[current_sub_name] = current_sub
current_sub_name = None
current_sub = None
def commit_interface():
nonlocal current_interface_name, current_interface
if current_interface_name:
# shallow copy to avoid future mutation
interfaces.append(dict(current_interface))
current_interface_name = None
current_interface = {}
for raw_line in lines:
line = raw_line.strip()
if line == "" or line.startswith("#"):
continue
if line.lower() == "[interfaces]":
continue
if line.startswith("[[[") and line.endswith("]]]"):
commit_sub()
current_sub_name = line[3:-3].strip()
current_sub = {}
continue
if line.startswith("[[") and line.endswith("]]"):
commit_sub()
commit_interface()
current_interface_name = line[2:-2].strip()
current_interface = {"name": current_interface_name}
continue
if "=" in line and current_interface_name is not None:
key, value = line.split("=", 1)
target = current_sub if current_sub is not None else current_interface
target[key.strip()] = value.strip()
# commit any pending sections
commit_sub()
commit_interface()
return interfaces

View File

@@ -0,0 +1,11 @@
class InterfaceEditor:
@staticmethod
def update_value(interface_details: dict, data: dict, key: str):
# update value if provided and not empty
value = data.get(key)
if value is not None and value != "":
interface_details[key] = value
return
# otherwise remove existing value
interface_details.pop(key, None)

View File

@@ -0,0 +1,135 @@
import threading
import time
import RNS
from RNS.Interfaces.Interface import Interface
from websockets.sync.client import connect
from websockets.sync.connection import Connection
class WebsocketClientInterface(Interface):
# TODO: required?
DEFAULT_IFAC_SIZE = 16
RECONNECT_DELAY_SECONDS = 5
def __str__(self):
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
def __init__(self, owner, configuration, websocket: Connection = None):
super().__init__()
self.owner = owner
self.parent_interface = None
self.IN = True
self.OUT = False
self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
# parse config
ifconf = Interface.get_config_obj(configuration)
self.name = ifconf.get("name")
self.target_url = ifconf.get("target_url", None)
# ensure target url is provided
if self.target_url is None:
raise SystemError(f"target_url is required for interface '{self.name}'")
# connect to websocket server if an existing connection was not provided
self.websocket = websocket
if self.websocket is None:
thread = threading.Thread(target=self.connect)
thread.daemon = True
thread.start()
# called when a full packet has been received over the websocket
def process_incoming(self, data):
# do nothing if offline or detached
if not self.online or self.detached:
return
# update received bytes counter
self.rxb += len(data)
# update received bytes counter for parent interface
if self.parent_interface is not None:
self.parent_interface.rxb += len(data)
# send received data to transport instance
self.owner.inbound(data, self)
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
def process_outgoing(self, data):
# do nothing if offline or detached
if not self.online or self.detached:
return
# send to websocket server
try:
self.websocket.send(data)
except Exception as e:
RNS.log(
f"Exception occurred while transmitting via {self!s}",
RNS.LOG_ERROR,
)
RNS.log(f"The contained exception was: {e!s}", RNS.LOG_ERROR)
return
# update sent bytes counter
self.txb += len(data)
# update received bytes counter for parent interface
if self.parent_interface is not None:
self.parent_interface.txb += len(data)
# connect to the configured websocket server
def connect(self):
# do nothing if interface is detached
if self.detached:
return
# connect to websocket server
try:
RNS.log(f"Connecting to Websocket for {self!s}...", RNS.LOG_DEBUG)
self.websocket = connect(
f"{self.target_url}",
max_size=None,
compression=None,
)
RNS.log(f"Connected to Websocket for {self!s}", RNS.LOG_DEBUG)
self.read_loop()
except Exception as e:
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
# auto reconnect after delay
RNS.log(f"Websocket disconnected for {self!s}...", RNS.LOG_DEBUG)
time.sleep(self.RECONNECT_DELAY_SECONDS)
self.connect()
def read_loop(self):
self.online = True
try:
for message in self.websocket:
self.process_incoming(message)
except Exception as e:
RNS.log(f"{self} read loop error: {e}", RNS.LOG_ERROR)
self.online = False
def detach(self):
# mark as offline
self.online = False
# close websocket
if self.websocket is not None:
self.websocket.close()
# mark as detached
self.detached = True
# set interface class RNS should use when importing this external interface
interface_class = WebsocketClientInterface

View File

@@ -0,0 +1,163 @@
import threading
import time
import RNS
from RNS.Interfaces.Interface import Interface
from src.backend.interfaces.WebsocketClientInterface import WebsocketClientInterface
from websockets.sync.server import Server, ServerConnection, serve
class WebsocketServerInterface(Interface):
# TODO: required?
DEFAULT_IFAC_SIZE = 16
RESTART_DELAY_SECONDS = 5
def __str__(self):
return (
f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
)
def __init__(self, owner, configuration):
super().__init__()
self.owner = owner
self.IN = True
self.OUT = False
self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
self.server: Server | None = None
self.spawned_interfaces: [WebsocketClientInterface] = []
# parse config
ifconf = Interface.get_config_obj(configuration)
self.name = ifconf.get("name")
self.listen_ip = ifconf.get("listen_ip", None)
self.listen_port = ifconf.get("listen_port", None)
# ensure listen ip is provided
if self.listen_ip is None:
raise SystemError(f"listen_ip is required for interface '{self.name}'")
# ensure listen port is provided
if self.listen_port is None:
raise SystemError(f"listen_port is required for interface '{self.name}'")
# convert listen port to int
self.listen_port = int(self.listen_port)
# run websocket server
thread = threading.Thread(target=self.serve)
thread.daemon = True
thread.start()
@property
def clients(self):
return len(self.spawned_interfaces)
# TODO docs
def received_announce(self, from_spawned=False):
if from_spawned:
self.ia_freq_deque.append(time.time())
# TODO docs
def sent_announce(self, from_spawned=False):
if from_spawned:
self.oa_freq_deque.append(time.time())
# do nothing as the spawned child interface will take care of rx/tx
def process_incoming(self, data):
pass
# do nothing as the spawned child interface will take care of rx/tx
def process_outgoing(self, data):
pass
def serve(self):
# handle new websocket client connections
def on_websocket_client_connected(websocket: ServerConnection):
# create new child interface
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
spawned_interface = WebsocketClientInterface(
self.owner,
{
"name": f"Client on {self.name}",
"target_host": websocket.remote_address[0],
"target_port": str(websocket.remote_address[1]),
},
websocket=websocket,
)
# configure child interface
spawned_interface.IN = self.IN
spawned_interface.OUT = self.OUT
spawned_interface.HW_MTU = self.HW_MTU
spawned_interface.bitrate = self.bitrate
spawned_interface.mode = self.mode
spawned_interface.parent_interface = self
spawned_interface.online = True
# TODO implement?
spawned_interface.announce_rate_target = None
spawned_interface.announce_rate_grace = None
spawned_interface.announce_rate_penalty = None
# TODO ifac?
# TODO announce rates?
# activate child interface
RNS.log(
f"Spawned new WebsocketClientInterface: {spawned_interface}",
RNS.LOG_VERBOSE,
)
RNS.Transport.interfaces.append(spawned_interface)
# associate child interface with this interface
while spawned_interface in self.spawned_interfaces:
self.spawned_interfaces.remove(spawned_interface)
self.spawned_interfaces.append(spawned_interface)
# run read loop
spawned_interface.read_loop()
# client must have disconnected as the read loop finished, so forget the spawned interface
self.spawned_interfaces.remove(spawned_interface)
# run websocket server
try:
RNS.log(f"Starting Websocket server for {self!s}...", RNS.LOG_DEBUG)
with serve(
on_websocket_client_connected,
self.listen_ip,
self.listen_port,
compression=None,
) as server:
self.online = True
self.server = server
server.serve_forever()
except Exception as e:
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
# websocket server is no longer running, let's restart it
self.online = False
RNS.log(f"Websocket server stopped for {self!s}...", RNS.LOG_DEBUG)
time.sleep(self.RESTART_DELAY_SECONDS)
self.serve()
def detach(self):
# mark as offline
self.online = False
# stop websocket server
if self.server is not None:
self.server.shutdown()
# mark as detached
self.detached = True
# set interface class RNS should use when importing this external interface
interface_class = WebsocketServerInterface

View File

@@ -0,0 +1 @@
"""Shared transport interfaces for MeshChatX."""

View File

@@ -1,9 +1,5 @@
from typing import List
# helper class for passing around an lxmf audio field
class LxmfAudioField:
def __init__(self, audio_mode: int, audio_bytes: bytes):
self.audio_mode = audio_mode
self.audio_bytes = audio_bytes
@@ -11,7 +7,6 @@ class LxmfAudioField:
# helper class for passing around an lxmf image field
class LxmfImageField:
def __init__(self, image_type: str, image_bytes: bytes):
self.image_type = image_type
self.image_bytes = image_bytes
@@ -19,7 +14,6 @@ class LxmfImageField:
# helper class for passing around an lxmf file attachment
class LxmfFileAttachment:
def __init__(self, file_name: str, file_bytes: bytes):
self.file_name = file_name
self.file_bytes = file_bytes
@@ -27,7 +21,5 @@ class LxmfFileAttachment:
# helper class for passing around an lxmf file attachments field
class LxmfFileAttachmentsField:
def __init__(self, file_attachments: List[LxmfFileAttachment]):
def __init__(self, file_attachments: list[LxmfFileAttachment]):
self.file_attachments = file_attachments

View File

@@ -0,0 +1,3 @@
# https://github.com/markqvist/Sideband/blob/e515889e210037f881c201e0d627a7b09a48eb69/sbapp/sideband/sense.py#L11
class SidebandCommands:
TELEMETRY_REQUEST = 0x01

View File

@@ -7,12 +7,6 @@
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
<title>Phone | Reticulum MeshChat</title>
<!-- codec2 -->
<script src="assets/js/codec2-emscripten/c2enc.js"></script>
<script src="assets/js/codec2-emscripten/c2dec.js"></script>
<script src="assets/js/codec2-emscripten/sox.js"></script>
<script src="assets/js/codec2-emscripten/codec2-lib.js"></script>
</head>
<body>
<div id="app"></div>

View File

@@ -0,0 +1,16 @@
import axios from 'axios';
import { createApp } from 'vue';
import "./style.css";
import CallPage from "./components/call/CallPage.vue";
import { ensureCodec2ScriptsLoaded } from "./js/Codec2Loader";
// provide axios globally
window.axios = axios;
async function bootstrap() {
await ensureCodec2ScriptsLoaded();
createApp(CallPage)
.mount('#app');
}
bootstrap();

View File

@@ -1,54 +1,86 @@
<template>
<div :class="{'dark': config?.theme === 'dark'}" class="h-screen w-full flex flex-col">
<div :class="{'dark': config?.theme === 'dark'}" class="h-screen w-full flex flex-col bg-slate-50 dark:bg-zinc-950 transition-colors">
<!-- header -->
<div class="flex bg-white dark:bg-zinc-950 p-2 border-gray-300 dark:border-zinc-900 border-b min-h-16">
<div class="flex w-full">
<div class="hidden sm:flex my-auto w-12 h-12 mr-2">
<img class="w-12 h-12" src="/assets/images/logo-chat-bubble.png" />
</div>
<div class="my-auto">
<div @click="onAppNameClick" class="font-bold cursor-pointer text-gray-900 dark:text-zinc-100">Reticulum MeshChat</div>
<div class="text-sm text-gray-700 dark:text-white">
Developed by
<a target="_blank" href="https://liamcottle.com" class="text-blue-500 dark:text-blue-400">Liam Cottle</a>
</div>
</div>
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
<button @click="syncPropagationNode" type="button" class="rounded-full">
<span class="flex text-gray-700 dark:text-white bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-600 px-2 py-1 rounded-full">
<span :class="{ 'animate-spin': isSyncingPropagationNode }">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</span>
<span class="hidden sm:inline-block my-auto mx-1 text-sm">Sync Messages</span>
</span>
</button>
<button @click="composeNewMessage" type="button" class="rounded-full">
<span class="flex text-gray-700 dark:text-white bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-600 px-2 py-1 rounded-full">
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
</svg>
</span>
<span class="hidden sm:inline-block my-auto mx-1 text-sm">Compose</span>
</span>
</button>
</div>
</div>
<div v-if="isPopoutMode" class="flex flex-1 h-full w-full overflow-hidden bg-slate-50/90 dark:bg-zinc-950">
<RouterView class="flex-1"/>
</div>
<!-- middle -->
<div ref="middle" class="flex h-full w-full overflow-auto">
<template v-else>
<!-- sidebar -->
<div class="bg-white flex w-72 min-w-72 flex-col dark:bg-zinc-950">
<div class="flex grow flex-col overflow-y-auto border-r border-gray-200 bg-white dark:border-zinc-900 dark:bg-zinc-950">
<!-- header -->
<div class="flex bg-white/80 dark:bg-zinc-900/70 backdrop-blur border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-sm transition-colors">
<div class="flex w-full">
<div class="hidden sm:flex my-auto w-12 h-12 mr-2 rounded-xl overflow-hidden bg-white/70 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 shadow-inner">
<img class="w-12 h-12 object-contain p-1.5" src="/assets/images/logo-chat-bubble.png" />
</div>
<div class="my-auto">
<div @click="onAppNameClick" class="font-semibold cursor-pointer text-gray-900 dark:text-zinc-100 tracking-tight text-lg">Reticulum MeshChatX</div>
<div class="text-sm text-gray-600 dark:text-zinc-300">
Custom fork by
<a target="_blank" href="https://github.com/Sudo-Ivan" class="text-blue-500 dark:text-blue-300 hover:underline">Sudo-Ivan</a>
</div>
</div>
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-2">
<button @click="syncPropagationNode" type="button" class="rounded-full">
<span class="flex text-gray-800 dark:text-zinc-100 bg-white dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 hover:border-blue-400 dark:hover:border-blue-400/60 px-3 py-1.5 rounded-full shadow-sm transition">
<span :class="{ 'animate-spin': isSyncingPropagationNode }">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</span>
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-medium">Sync Messages</span>
</span>
</button>
<button @click="composeNewMessage" type="button" class="rounded-full">
<span class="flex text-white bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 hover:from-blue-500/90 hover:to-purple-500/90 px-3 py-1.5 rounded-full shadow-md transition">
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
</svg>
</span>
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-semibold">Compose</span>
</span>
</button>
</div>
</div>
</div>
<!-- navigation -->
<div class="flex-1">
<ul class="py-2 pr-2 space-y-1">
<!-- onboarding / guidance -->
<div v-if="hasGuidanceMessages" class="border-b border-amber-200/60 bg-amber-50/70 text-amber-900 dark:bg-amber-950/30 dark:border-amber-800/40 dark:text-amber-100 transition">
<div class="max-w-5xl mx-auto px-4 py-4 space-y-3">
<div
v-for="message in guidanceMessages"
:key="message.id"
class="flex flex-col gap-2 rounded-2xl border p-4 text-sm sm:flex-row sm:items-center shadow-sm"
:class="guidanceCardClass(message)"
>
<div class="space-y-1">
<div class="font-semibold">{{ message.title }}</div>
<div class="text-xs sm:text-sm text-amber-900/80 dark:text-amber-100/80">{{ message.description }}</div>
</div>
<div v-if="message.action_route" class="sm:ml-auto">
<button
type="button"
@click="navigateTo(message.action_route)"
class="inline-flex items-center rounded-full bg-amber-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-amber-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
{{ message.action_label || 'Open' }}
</button>
</div>
</div>
</div>
</div>
<!-- middle -->
<div ref="middle" class="flex h-full w-full overflow-hidden bg-slate-50/80 dark:bg-zinc-950 transition-colors">
<!-- sidebar -->
<div class="bg-transparent flex w-72 min-w-72 flex-col">
<div class="flex grow flex-col overflow-y-auto border-r border-gray-200/70 bg-white/80 dark:border-zinc-800 dark:bg-zinc-900/70 backdrop-blur">
<!-- navigation -->
<div class="flex-1">
<ul class="py-3 pr-2 space-y-1">
<!-- messages -->
<li>
@@ -144,8 +176,8 @@
<div>
<!-- my identity -->
<div v-if="config" class="bg-white border-t dark:border-zinc-900 dark:bg-zinc-950">
<div @click="isShowingMyIdentitySection = !isShowingMyIdentitySection" class="flex text-gray-700 p-2 cursor-pointer">
<div v-if="config" class="bg-white/80 border-t dark:border-zinc-800 dark:bg-zinc-900/70 backdrop-blur">
<div @click="isShowingMyIdentitySection = !isShowingMyIdentitySection" class="flex text-gray-700 p-3 cursor-pointer">
<div class="my-auto mr-2">
<RouterLink @click.stop :to="{ name: 'profile.icon' }">
<LxmfUserIcon
@@ -162,8 +194,8 @@
</button>
</div>
</div>
<div v-if="isShowingMyIdentitySection" class="divide-y text-gray-900 border-t border-gray-300 dark:text-zinc-200 dark:border-zinc-900">
<div class="p-1">
<div v-if="isShowingMyIdentitySection" class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-800">
<div class="p-2">
<input
v-model="displayName"
type="text"
@@ -172,11 +204,11 @@
dark:bg-zinc-800 dark:border-zinc-600 dark:text-zinc-200 dark:focus:ring-blue-400 dark:focus:border-blue-400"
>
</div>
<div class="p-1 dark:border-zinc-900">
<div class="p-2 dark:border-zinc-900">
<div>Identity Hash</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.identity_hash }}</div>
</div>
<div class="p-1 dark:border-zinc-900">
<div class="p-2 dark:border-zinc-900">
<div>LXMF Address</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.lxmf_address_hash }}</div>
</div>
@@ -184,8 +216,8 @@
</div>
<!-- auto announce -->
<div v-if="config" class="bg-white border-t dark:bg-zinc-950 dark:border-zinc-900">
<div @click="isShowingAnnounceSection = !isShowingAnnounceSection" class="flex text-gray-700 p-2 cursor-pointer dark:text-white">
<div v-if="config" class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-800">
<div @click="isShowingAnnounceSection = !isShowingAnnounceSection" class="flex text-gray-700 p-3 cursor-pointer dark:text-white">
<div class="my-auto mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -214,8 +246,8 @@
</button>
</div>
</div>
<div v-if="isShowingAnnounceSection" class="divide-y text-gray-900 border-t border-gray-300 dark:text-zinc-200 dark:border-zinc-900">
<div class="p-1 dark:border-zinc-900">
<div v-if="isShowingAnnounceSection" class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-900">
<div class="p-2 dark:border-zinc-900">
<select
v-model="config.auto_announce_interval_seconds"
@change="onAnnounceIntervalSecondsChange"
@@ -240,8 +272,8 @@
</div>
<!-- audio calls -->
<div v-if="config" class="bg-white border-t dark:bg-zinc-950 dark:border-zinc-900">
<div @click="isShowingCallsSection = !isShowingCallsSection" class="flex text-gray-700 p-2 cursor-pointer">
<div v-if="config" class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-900">
<div @click="isShowingCallsSection = !isShowingCallsSection" class="flex text-gray-700 p-3 cursor-pointer">
<div class="my-auto mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="dark:text-white w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
@@ -249,19 +281,15 @@
</div>
<div class="my-auto dark:text-white">Calls</div>
<div class="ml-auto">
<a @click.stop href="../call.html" target="_blank" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500
dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500
">
<span>Open Phone</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Z" clip-rule="evenodd" />
<path fill-rule="evenodd" d="M6.194 12.753a.75.75 0 0 0 1.06.053L16.5 4.44v2.81a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.553l-9.056 8.194a.75.75 0 0 0-.053 1.06Z" clip-rule="evenodd" />
<RouterLink :to="{ name: 'call' }" class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 transition-colors overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4 flex-shrink-0">
<path fill-rule="evenodd" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" clip-rule="evenodd" />
</svg>
</a>
</RouterLink>
</div>
</div>
<div v-if="isShowingCallsSection" class="divide-y text-gray-900 border-t border-gray-300 dark:border-zinc-900">
<div class="p-1 flex dark:border-zinc-900 dark:text-white">
<div v-if="isShowingCallsSection" class="divide-y text-gray-900 border-t border-gray-200 dark:border-zinc-900">
<div class="p-2 flex dark:border-zinc-900 dark:text-white">
<div>
<div>Status</div>
<div class="text-sm text-gray-700 dark:text-white">
@@ -299,9 +327,10 @@ dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outli
</div>
</div>
<RouterView/>
<RouterView v-if="!isPopoutMode"/>
</div>
</div>
</template>
</div>
</template>
@@ -326,6 +355,7 @@ export default {
return {
reloadInterval: null,
appInfoInterval: null,
isShowingMyIdentitySection: true,
isShowingAnnounceSection: true,
@@ -343,6 +373,7 @@ export default {
beforeUnmount() {
clearInterval(this.reloadInterval);
clearInterval(this.appInfoInterval);
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
@@ -354,6 +385,7 @@ export default {
WebSocketConnection.on("message", this.onWebsocketMessage);
this.getAppInfo();
this.getConfig();
this.updateCallsList();
this.updatePropagationNodeStatus();
@@ -362,9 +394,52 @@ export default {
this.updateCallsList();
this.updatePropagationNodeStatus();
}, 3000);
this.appInfoInterval = setInterval(() => {
this.getAppInfo();
}, 15000);
},
computed: {
currentPopoutType() {
if(this.$route?.meta?.popoutType){
return this.$route.meta.popoutType;
}
return this.$route?.query?.popout ?? this.getHashPopoutValue();
},
isPopoutMode() {
return this.currentPopoutType != null;
},
hasGuidanceMessages() {
return this.guidanceMessages.length > 0;
},
guidanceMessages() {
if (!this.appInfo || !Array.isArray(this.appInfo.user_guidance)) {
return [];
}
return this.appInfo.user_guidance;
},
},
methods: {
guidanceCardClass(message) {
switch(message.severity){
case 'warning':
return 'border-amber-200 bg-white text-amber-900 dark:bg-transparent dark:border-amber-300/40';
case 'info':
default:
return 'border-amber-100 bg-white text-amber-900 dark:bg-transparent dark:border-amber-200/30';
}
},
navigateTo(routePath) {
if (!routePath) {
return;
}
this.$router.push(routePath);
},
getHashPopoutValue() {
const hash = window.location.hash || "";
const match = hash.match(/popout=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
switch(json.type){
@@ -448,7 +523,7 @@ export default {
// ask to stop syncing if already syncing
if(this.isSyncingPropagationNode){
if(confirm("Are you sure you want to stop syncing?")){
if(await DialogUtils.confirm("Are you sure you want to stop syncing?")){
await this.stopSyncingPropagationNode();
}
return;
@@ -529,7 +604,7 @@ export default {
async hangupAllCalls() {
// confirm user wants to hang up calls
if(!confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
if(!await DialogUtils.confirm("Are you sure you want to hang up all incoming and outgoing calls?")){
return;
}

View File

@@ -14,7 +14,7 @@
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95">
<div v-if="isShowingMenu" @click.stop="hideMenu" class="overflow-hidden absolute right-0 z-10 mr-4 w-56 rounded-md bg-white shadow-md border border-gray-200 focus:outline-none" :class="[ dropdownClass ]">
<div v-if="isShowingMenu" @click.stop="hideMenu" class="overflow-hidden absolute right-0 z-10 mr-4 w-56 rounded-md bg-white shadow-md border border-gray-200 focus:outline-none dark:border-zinc-700" :class="[ dropdownClass ]">
<slot name="items"/>
</div>
</Transition>

View File

@@ -0,0 +1,11 @@
<template>
<div class="cursor-pointer flex p-3 space-x-2 text-sm bg-white text-gray-500 hover:bg-gray-100 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700">
<slot/>
</div>
</template>
<script>
export default {
name: 'DropDownMenuItem',
}
</script>

View File

@@ -0,0 +1,11 @@
<template>
<button type="button" class="text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 rounded-full w-8 h-8 flex items-center justify-center flex-shrink-0">
<slot/>
</button>
</template>
<script>
export default {
name: 'IconButton',
}
</script>

View File

@@ -1,11 +1,11 @@
<template>
<RouterLink :to="to" v-slot="{ href, route, navigate, isActive, isExactActive }" custom>
<RouterLink :to="to" v-slot="{ href, route, navigate, isActive }" custom>
<a
:href="href"
@click="handleNavigate($event, navigate)"
type="button"
:class="[
isExactActive
isActive
? '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'
]"

View File

@@ -0,0 +1,298 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900">
<div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-6xl mx-auto">
<div v-if="appInfo" class="glass-card">
<div class="flex flex-col gap-4 md:flex-row md:items-center">
<div class="flex-1 space-y-2">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">About</div>
<div class="text-3xl font-semibold text-gray-900 dark:text-white">Reticulum MeshChatX</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
v{{ appInfo.version }} RNS {{ appInfo.rns_version }} LXMF {{ appInfo.lxmf_version }} Python {{ appInfo.python_version }}
</div>
</div>
<div v-if="isElectron" class="flex flex-col sm:flex-row gap-2">
<button @click="relaunch" type="button" class="primary-chip px-4 py-2 text-sm justify-center">
<MaterialDesignIcon icon-name="restart" class="w-4 h-4"/>
Restart App
</button>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-3 mt-4 text-sm text-gray-700 dark:text-gray-300">
<div>
<div class="glass-label">Config path</div>
<div class="monospace-field break-all">{{ appInfo.reticulum_config_path }}</div>
<button v-if="isElectron" @click="showReticulumConfigFile" type="button" class="secondary-chip mt-2 text-xs">
<MaterialDesignIcon icon-name="folder" class="w-4 h-4"/>
Reveal
</button>
</div>
<div>
<div class="glass-label">Database path</div>
<div class="monospace-field break-all">{{ appInfo.database_path }}</div>
<button v-if="isElectron" @click="showDatabaseFile" type="button" class="secondary-chip mt-2 text-xs">
<MaterialDesignIcon icon-name="database" class="w-4 h-4"/>
Reveal
</button>
</div>
<div>
<div class="glass-label">Database size</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ formatBytes(appInfo.database_file_size) }}</div>
</div>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div v-if="appInfo?.memory_usage" class="glass-card space-y-3">
<header class="flex items-center gap-2">
<MaterialDesignIcon icon-name="chip" class="w-5 h-5 text-blue-500"/>
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">System Resources</div>
<div class="text-xs text-emerald-500 flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
Live
</div>
</div>
</header>
<div class="metric-row">
<div>
<div class="glass-label">Memory (RSS)</div>
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.rss) }}</div>
</div>
<div>
<div class="glass-label">Virtual Memory</div>
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.vms) }}</div>
</div>
</div>
</div>
<div v-if="appInfo?.network_stats" class="glass-card space-y-3">
<header class="flex items-center gap-2">
<MaterialDesignIcon icon-name="access-point-network" class="w-5 h-5 text-purple-500"/>
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">Network Stats</div>
<div class="text-xs text-emerald-500 flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
Live
</div>
</div>
</header>
<div class="metric-row">
<div>
<div class="glass-label">Sent</div>
<div class="metric-value">{{ formatBytes(appInfo.network_stats.bytes_sent) }}</div>
</div>
<div>
<div class="glass-label">Received</div>
<div class="metric-value">{{ formatBytes(appInfo.network_stats.bytes_recv) }}</div>
</div>
</div>
<div class="metric-row">
<div>
<div class="glass-label">Packets Sent</div>
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_sent) }}</div>
</div>
<div>
<div class="glass-label">Packets Received</div>
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_recv) }}</div>
</div>
</div>
</div>
<div v-if="appInfo?.reticulum_stats" class="glass-card space-y-3">
<header class="flex items-center gap-2">
<MaterialDesignIcon icon-name="diagram-projector" class="w-5 h-5 text-indigo-500"/>
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">Reticulum Stats</div>
<div class="text-xs text-emerald-500 flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
Live
</div>
</div>
</header>
<div class="metric-grid">
<div>
<div class="glass-label">Total Paths</div>
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.total_paths) }}</div>
</div>
<div>
<div class="glass-label">Announces / sec</div>
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.announces_per_second) }}</div>
</div>
<div>
<div class="glass-label">Announces / min</div>
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.announces_per_minute) }}</div>
</div>
<div>
<div class="glass-label">Announces / hr</div>
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.announces_per_hour) }}</div>
</div>
</div>
</div>
<div v-if="appInfo?.download_stats" class="glass-card space-y-3">
<header class="flex items-center gap-2">
<MaterialDesignIcon icon-name="download" class="w-5 h-5 text-sky-500"/>
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">Download Activity</div>
<div class="text-xs text-emerald-500 flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
Live
</div>
</div>
</header>
<div class="metric-value">
<span v-if="appInfo.download_stats.avg_download_speed_bps !== null">
{{ formatBytesPerSecond(appInfo.download_stats.avg_download_speed_bps) }}
</span>
<span v-else class="text-sm text-gray-500">No downloads yet</span>
</div>
</div>
</div>
<div v-if="appInfo" class="glass-card space-y-3">
<div class="text-lg font-semibold text-gray-900 dark:text-white">Runtime Status</div>
<div class="flex flex-wrap gap-3">
<span :class="statusPillClass(!appInfo.is_connected_to_shared_instance)">
<MaterialDesignIcon icon-name="server" class="w-4 h-4"/>
{{ appInfo.is_connected_to_shared_instance ? 'Shared Instance' : 'Standalone Instance' }}
</span>
<span :class="statusPillClass(appInfo.is_transport_enabled)">
<MaterialDesignIcon icon-name="transit-connection" class="w-4 h-4"/>
{{ appInfo.is_transport_enabled ? 'Transport Enabled' : 'Transport Disabled' }}
</span>
</div>
</div>
<div v-if="config" class="glass-card space-y-4">
<div class="text-lg font-semibold text-gray-900 dark:text-white">Identity & Addresses</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="address-card">
<div class="glass-label">Identity Hash</div>
<div class="monospace-field break-all">{{ config.identity_hash }}</div>
<button @click="copyValue(config.identity_hash, 'Identity Hash')" type="button" class="secondary-chip mt-3 text-xs">
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
Copy
</button>
</div>
<div class="address-card">
<div class="glass-label">LXMF Address</div>
<div class="monospace-field break-all">{{ config.lxmf_address_hash }}</div>
<button @click="copyValue(config.lxmf_address_hash, 'LXMF Address')" type="button" class="secondary-chip mt-3 text-xs">
<MaterialDesignIcon icon-name="account-network" class="w-4 h-4"/>
Copy
</button>
</div>
<div class="address-card">
<div class="glass-label">Propagation Node</div>
<div class="monospace-field break-all">{{ config.lxmf_local_propagation_node_address_hash || '—' }}</div>
</div>
<div class="address-card">
<div class="glass-label">Audio Call Address</div>
<div class="monospace-field break-all">{{ config.audio_call_address_hash || '—' }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Utils from "../../js/Utils";
import ElectronUtils from "../../js/ElectronUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DialogUtils from "../../js/DialogUtils";
export default {
name: 'AboutPage',
components: {
MaterialDesignIcon,
},
data() {
return {
appInfo: null,
config: null,
updateInterval: null,
};
},
mounted() {
this.getAppInfo();
this.getConfig();
// Update stats every 5 seconds
this.updateInterval = setInterval(() => {
this.getAppInfo();
}, 5000);
},
beforeUnmount() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
},
methods: {
async getAppInfo() {
try {
const response = await window.axios.get("/api/v1/app/info");
this.appInfo = response.data.app_info;
} catch(e) {
// do nothing if failed to load app info
console.log(e);
}
},
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch(e) {
// do nothing if failed to load config
console.log(e);
}
},
async copyValue(value, label) {
if(!value){
return;
}
try {
await navigator.clipboard.writeText(value);
DialogUtils.toast?.(`${label} copied`) ?? DialogUtils.alert(`${label} copied to clipboard`);
} catch(e) {
DialogUtils.alert(`Failed to copy ${label}`);
}
},
relaunch() {
ElectronUtils.relaunch();
},
showReticulumConfigFile() {
const reticulumConfigPath = this.appInfo.reticulum_config_path;
if(reticulumConfigPath){
ElectronUtils.showPathInFolder(reticulumConfigPath);
}
},
showDatabaseFile() {
const databasePath = this.appInfo.database_path;
if(databasePath){
ElectronUtils.showPathInFolder(databasePath);
}
},
formatBytes: function(bytes) {
return Utils.formatBytes(bytes);
},
formatNumber: function(num) {
return Utils.formatNumber(num);
},
formatBytesPerSecond: function(bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
statusPillClass(isGood) {
return isGood
? "inline-flex items-center gap-1 rounded-full bg-emerald-100 text-emerald-700 px-3 py-1 text-xs font-semibold"
: "inline-flex items-center gap-1 rounded-full bg-orange-100 text-orange-700 px-3 py-1 text-xs font-semibold";
},
},
computed: {
isElectron() {
return ElectronUtils.isElectron();
},
},
}
</script>

View File

@@ -1,61 +1,62 @@
<template>
<div class="flex w-full h-full bg-gray-100 dark:bg-zinc-950" :class="{'dark': config?.theme === 'dark'}">
<div class="mx-auto my-auto w-full max-w-xl p-4">
<div class="flex w-full h-full bg-gray-50 dark:bg-zinc-950" :class="{'dark': config?.theme === 'dark'}">
<div class="mx-auto my-auto w-full max-w-2xl p-4 sm:p-6">
<!-- in active call -->
<div v-if="isWebsocketConnected" class="w-full">
<div class="border rounded-xl bg-white shadow w-full">
<div class="flex border-b border-gray-300 text-gray-700 p-2">
<div class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-lg w-full overflow-hidden">
<div class="flex items-center border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm px-4 py-3">
<div class="my-auto mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
</svg>
</div>
<div class="my-auto">Active Call</div>
<div class="font-semibold text-gray-900 dark:text-zinc-100">Active Call</div>
</div>
<div class="border-b border-gray-300 text-gray-700 p-2">
<div class="border-b border-gray-200 dark:border-zinc-800 p-4 space-y-3">
<div class="mb-2">
<div class="mb-1 text-sm font-medium text-gray-900">Call Hash</div>
<div class="text-xs text-gray-600">{{ audioCall?.hash || "Unknown" }}</div>
<div>
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Call Hash</div>
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono">{{ audioCall?.hash || "Unknown" }}</div>
</div>
<div class="mb-2">
<div class="mb-1 text-sm font-medium text-gray-900">Remote Identity Hash</div>
<div class="text-xs text-gray-600">{{ audioCall?.remote_identity_hash || "Unknown" }}</div>
<div>
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Remote Identity Hash</div>
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono">{{ audioCall?.remote_identity_hash || "Unknown" }}</div>
</div>
<div class="mb-2">
<div class="mb-1 text-sm font-medium text-gray-900">Remote Destination Hash</div>
<div class="text-xs text-gray-600">{{ audioCall?.remote_destination_hash || "Unknown" }}</div>
<div>
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Remote Destination Hash</div>
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono">{{ audioCall?.remote_destination_hash || "Unknown" }}</div>
</div>
<div class="mb-2">
<div class="mb-1 text-sm font-medium text-gray-900">Path</div>
<div class="text-xs text-gray-600">
<div>
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Path</div>
<div class="text-sm text-gray-900 dark:text-zinc-100">
<span v-if="audioCall?.path">{{ audioCall.path.hops }} {{ audioCall.path.hops === 1 ? 'hop' : 'hops' }} away via {{ audioCall.path.next_hop_interface }}</span>
<span v-else>Unknown</span>
</div>
</div>
<div class="mb-2">
<div class="mb-1 text-sm font-medium text-gray-900">TX Bytes</div>
<div class="text-xs text-gray-600">{{ formatBytes(txBytes) }}</div>
</div>
<div class="mb-2">
<div class="mb-1 text-sm font-medium text-gray-900">RX Bytes</div>
<div class="text-xs text-gray-600">{{ formatBytes(rxBytes) }}</div>
</div>
<div class="mb-2">
<div class="mb-1 text-sm font-medium text-gray-900">Incoming Audio</div>
<div class="text-xs text-gray-600">{{ remoteAudioCodec || "Unknown" }}</div>
<div class="grid grid-cols-2 gap-3">
<div>
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">TX Bytes</div>
<div class="text-sm text-gray-900 dark:text-zinc-100 font-semibold">{{ formatBytes(txBytes) }}</div>
</div>
<div>
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">RX Bytes</div>
<div class="text-sm text-gray-900 dark:text-zinc-100 font-semibold">{{ formatBytes(rxBytes) }}</div>
</div>
</div>
<div>
<div class="mb-1 text-sm font-medium text-gray-900">Outgoing Audio</div>
<select v-model="codecMode" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Incoming Audio</div>
<div class="text-sm text-gray-900 dark:text-zinc-100">{{ remoteAudioCodec || "Unknown" }}</div>
</div>
<div>
<div class="mb-1 text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">Outgoing Audio</div>
<select v-model="codecMode" class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 block w-full px-3 py-2 shadow-sm transition-all">
<option value="MODE_3200">Codec2 3200</option>
<option value="MODE_2400">Codec2 2400</option>
<option value="MODE_1600">Codec2 1600</option>
@@ -69,10 +70,10 @@
</div>
</div>
<div class="flex text-gray-900 p-2">
<div class="flex items-center gap-2 px-4 py-3 bg-gray-50 dark:bg-zinc-900/50">
<!-- toggle mic -->
<button @click="isMicMuted = !isMicMuted" type="button" :class="[ isMicMuted ? 'bg-red-500 hover:bg-red-400 focus-visible:outline-red-500' : 'bg-gray-500 hover:bg-gray-400 focus-visible:outline-gray-500' ]" class="my-auto inline-flex items-center gap-x-1 rounded-full p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
<button @click="isMicMuted = !isMicMuted" type="button" :class="[ isMicMuted ? 'bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 focus-visible:outline-red-500' : 'bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 focus-visible:outline-gray-500' ]" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
<svg v-if="isMicMuted" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="w-5 h-5">
<path d="M213.38,229.92a8,8,0,0,1-11.3-.54l-30.92-34A78.83,78.83,0,0,1,136,207.59V240a8,8,0,0,1-16,0V207.6A80.11,80.11,0,0,1,48,128a8,8,0,0,1,16,0,64.07,64.07,0,0,0,64,64,63.41,63.41,0,0,0,32.21-8.68l-11.1-12.2A48,48,0,0,1,80,128V95.09L42.08,53.38A8,8,0,0,1,53.92,42.62l160,176A8,8,0,0,1,213.38,229.92Zm-24.19-63.13a7.88,7.88,0,0,0,3.51.82,8,8,0,0,0,7.19-4.49A79.16,79.16,0,0,0,208,128a8,8,0,0,0-16,0,63.32,63.32,0,0,1-6.48,28.09A8,8,0,0,0,189.19,166.79Zm-27.33-29.22A8,8,0,0,0,175.74,133a49.49,49.49,0,0,0,.26-5V64A48,48,0,0,0,84,44.87a8,8,0,0,0,1.41,8.57Z"></path>
</svg>
@@ -82,7 +83,7 @@
</button>
<!-- toggle sound -->
<button @click="isSoundMuted = !isSoundMuted" type="button" :class="[ isSoundMuted ? 'bg-red-500 hover:bg-red-400 focus-visible:outline-red-500' : 'bg-gray-500 hover:bg-gray-400 focus-visible:outline-gray-500' ]" class="ml-1 my-auto inline-flex items-center gap-x-1 rounded-full p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
<button @click="isSoundMuted = !isSoundMuted" type="button" :class="[ isSoundMuted ? 'bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 focus-visible:outline-red-500' : 'bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 focus-visible:outline-gray-500' ]" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
<svg v-if="isSoundMuted" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M10.047 3.062a.75.75 0 0 1 .453.688v12.5a.75.75 0 0 1-1.264.546L5.203 13H2.667a.75.75 0 0 1-.7-.48A6.985 6.985 0 0 1 1.5 10c0-.887.165-1.737.468-2.52a.75.75 0 0 1 .7-.48h2.535l4.033-3.796a.75.75 0 0 1 .811-.142ZM13.78 7.22a.75.75 0 1 0-1.06 1.06L14.44 10l-1.72 1.72a.75.75 0 0 0 1.06 1.06l1.72-1.72 1.72 1.72a.75.75 0 1 0 1.06-1.06L16.56 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L15.5 8.94l-1.72-1.72Z" />
</svg>
@@ -93,7 +94,7 @@
</button>
<!-- leave call -->
<button @click="leaveCall" type="button" class="ml-auto mr-1 my-auto inline-flex items-center gap-x-1 rounded-full bg-blue-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
<button @click="leaveCall" type="button" class="ml-auto inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clip-rule="evenodd" />
</svg>
@@ -101,7 +102,7 @@
</button>
<!-- hangup call -->
<button @click="hangupCall(audioCall.hash)" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full bg-red-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
<button @click="hangupCall(audioCall.hash)" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 rotate-[135deg] translate-y-0.5">
<path fill-rule="evenodd" d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" clip-rule="evenodd" />
</svg>
@@ -116,60 +117,56 @@
<div v-else class="w-full space-y-2">
<!-- dialer -->
<div class="border rounded-xl bg-white shadow w-full overflow-hidden dark:border-zinc-900">
<div class="flex border-b border-gray-300 text-gray-700 p-2 dark:bg-zinc-800 dark:text-white">
<div class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-lg w-full overflow-hidden">
<div class="flex items-center border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm px-4 py-3">
<div class="my-auto mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 dark:text-white">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
</svg>
</div>
<div class="my-auto">Start a new Call</div>
<div class="font-semibold text-gray-900 dark:text-zinc-100">Start a new Call</div>
</div>
<div class="flex border-b border-gray-300 text-gray-900 p-2 space-x-2 dark:bg-zinc-700 dark:text-zinc-100 dark:border-zinc-800">
<div class="flex-1">
<input v-model="destinationHash" type="text" placeholder="Enter Destination Hash" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2 dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100">
<div class="p-4 space-y-3">
<div class="flex gap-2">
<input v-model="destinationHash" @keydown.enter.exact.prevent="initiateCall(destinationHash)" type="text" placeholder="Enter Destination Hash" class="flex-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500">
<button @click="initiateCall(destinationHash)" :disabled="isInitiatingCall || !destinationHash || destinationHash.trim() === ''" type="button" :class="[ isInitiatingCall || !destinationHash || destinationHash.trim() === '' ? 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 focus-visible:outline-green-500' ]" class="inline-flex items-center gap-x-1.5 rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
<svg v-if="isInitiatingCall" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span v-if="isInitiatingCall">Calling...</span>
<span v-else>Call</span>
</button>
</div>
<button @click="initiateCall(destinationHash)" :disabled="isInitiatingCall" type="button" :class="[ isInitiatingCall ? 'bg-gray-400 focus-visible:outline-gray-500' : 'bg-green-500 hover:bg-green-400 focus-visible:outline-green-500' ]" class="my-auto inline-flex items-center gap-x-1 rounded-md p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
<span v-if="isInitiatingCall">
<span>Calling...</span>
</span>
<span v-else>Initiate Call</span>
</button>
</div>
<div class="flex p-1 dark:bg-zinc-700 dark:border-zinc-600">
<div class="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-zinc-800 bg-gray-50 dark:bg-zinc-900/50">
<div>
<div class='dark:text-white'>My Destination Hash</div>
<div class="text-sm text-gray-700 dark:text-zinc-100">{{ myAudioCallAddressHash || "Unknown" }}</div>
</div>
<div class="ml-auto my-auto mr-1">
<a @click="announce" href="javascript:void(0)" class="rounded-full">
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z" />
</svg>
</div>
<div class="my-auto mx-1 text-sm">Announce</div>
</div>
</a>
<div class="text-xs font-semibold text-gray-500 dark:text-zinc-400 uppercase tracking-wide">My Destination Hash</div>
<div class="text-sm text-gray-900 dark:text-zinc-100 font-mono mt-0.5">{{ myAudioCallAddressHash || "Unknown" }}</div>
</div>
<button @click="announce" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z" />
</svg>
<span>Announce</span>
</button>
</div>
</div>
<!-- active calls -->
<div v-if="activeAudioCalls.length > 0" class="border rounded-xl bg-white shadow w-full overflow-hidden dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100">
<div class="flex border-b border-gray-300 text-gray-700 p-2 dark:text-zinc-100">
<div v-if="activeAudioCalls.length > 0" class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-lg w-full overflow-hidden">
<div class="flex items-center border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm px-4 py-3">
<div class="my-auto mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
</div>
<div class="my-auto">Active Calls</div>
<div class="font-semibold text-gray-900 dark:text-zinc-100">Active Calls</div>
</div>
<div class="divide-y">
<div v-for="audioCall in activeAudioCalls" class="flex p-2">
<div class="mr-2 my-auto">
<div class="bg-gray-100 p-2 rounded-full">
<div class="divide-y divide-gray-200 dark:divide-zinc-800">
<div v-for="audioCall in activeAudioCalls" class="flex items-center p-4 hover:bg-gray-50 dark:hover:bg-zinc-900/50 transition-colors">
<div class="mr-3 flex-shrink-0">
<div class="bg-gray-100 dark:bg-zinc-800 p-2.5 rounded-xl">
<svg v-if="audioCall.is_outbound" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M3.5 2A1.5 1.5 0 0 0 2 3.5V5c0 1.149.15 2.263.43 3.326a13.022 13.022 0 0 0 9.244 9.244c1.063.28 2.177.43 3.326.43h1.5a1.5 1.5 0 0 0 1.5-1.5v-1.148a1.5 1.5 0 0 0-1.175-1.465l-3.223-.716a1.5 1.5 0 0 0-1.767 1.052l-.267.933c-.117.41-.555.643-.95.48a11.542 11.542 0 0 1-6.254-6.254c-.163-.395.07-.833.48-.95l.933-.267a1.5 1.5 0 0 0 1.052-1.767l-.716-3.223A1.5 1.5 0 0 0 4.648 2H3.5ZM16.5 4.56l-3.22 3.22a.75.75 0 1 1-1.06-1.06l3.22-3.22h-2.69a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0V4.56Z" />
</svg>
@@ -178,24 +175,24 @@
</svg>
</div>
</div>
<div>
<div>{{ audioCall.remote_destination_hash || "Unknown" }}</div>
<div class="text-sm text-gray-500 dark:text-zinc-100">
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 dark:text-zinc-100 truncate">{{ audioCall.remote_destination_hash || "Unknown" }}</div>
<div class="text-sm text-gray-500 dark:text-zinc-400">
<span v-if="audioCall.is_outbound">Outgoing Call...</span>
<span v-else>Incoming Call...</span>
</div>
</div>
<div class="flex space-x-2 ml-auto my-auto mx-2">
<div class="flex items-center gap-2 ml-auto flex-shrink-0">
<!-- rejoin call -->
<button v-if="audioCall.is_active" title="Join Call" @click="joinCall(audioCall.hash)" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full bg-green-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-green-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-500">
<button v-if="audioCall.is_active" title="Join Call" @click="joinCall(audioCall.hash)" type="button" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" clip-rule="evenodd" />
</svg>
</button>
<!-- hangup call -->
<button v-if="audioCall.is_active" title="Hangup Call" @click="hangupCall(audioCall.hash)" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full bg-red-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
<button v-if="audioCall.is_active" title="Hangup Call" @click="hangupCall(audioCall.hash)" type="button" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 rotate-[135deg] translate-y-0.5">
<path fill-rule="evenodd" d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" clip-rule="evenodd" />
</svg>
@@ -207,24 +204,22 @@
</div>
<!-- call history -->
<div v-if="inactiveAudioCalls.length > 0" class="border rounded-xl bg-white shadow w-full overflow-hidden dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100">
<div class="flex border-b border-gray-300 text-gray-700 p-2">
<div v-if="inactiveAudioCalls.length > 0" class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-lg w-full overflow-hidden">
<div class="flex items-center justify-between border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm px-4 py-3">
<div class="my-auto mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
</div>
<div class="my-auto">Call History</div>
<div class="ml-auto">
<button @click="clearCallHistory" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
Clear All
</button>
</div>
<div class="font-semibold text-gray-900 dark:text-zinc-100">Call History</div>
<button @click="clearCallHistory" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 px-3 py-1.5 text-xs font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
Clear All
</button>
</div>
<div class="divide-y">
<div v-for="audioCall in inactiveAudioCalls" class="group flex p-2">
<div class="mr-2 my-auto">
<div class="bg-gray-100 p-2 rounded-full">
<div class="divide-y divide-gray-200 dark:divide-zinc-800">
<div v-for="audioCall in inactiveAudioCalls" class="group flex items-center p-4 hover:bg-gray-50 dark:hover:bg-zinc-900/50 transition-colors">
<div class="mr-3 flex-shrink-0">
<div class="bg-gray-100 dark:bg-zinc-800 p-2.5 rounded-xl">
<svg v-if="audioCall.is_outbound" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M3.5 2A1.5 1.5 0 0 0 2 3.5V5c0 1.149.15 2.263.43 3.326a13.022 13.022 0 0 0 9.244 9.244c1.063.28 2.177.43 3.326.43h1.5a1.5 1.5 0 0 0 1.5-1.5v-1.148a1.5 1.5 0 0 0-1.175-1.465l-3.223-.716a1.5 1.5 0 0 0-1.767 1.052l-.267.933c-.117.41-.555.643-.95.48a11.542 11.542 0 0 1-6.254-6.254c-.163-.395.07-.833.48-.95l.933-.267a1.5 1.5 0 0 0 1.052-1.767l-.716-3.223A1.5 1.5 0 0 0 4.648 2H3.5ZM16.5 4.56l-3.22 3.22a.75.75 0 1 1-1.06-1.06l3.22-3.22h-2.69a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0V4.56Z" />
</svg>
@@ -233,14 +228,14 @@
</svg>
</div>
</div>
<div>
<div>Destination: {{ audioCall.remote_destination_hash || "Unknown" }}</div>
<div class="text-sm text-gray-500">Call Hash: {{ audioCall.hash }}</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 dark:text-zinc-100 truncate">{{ audioCall.remote_destination_hash || "Unknown" }}</div>
<div class="text-sm text-gray-500 dark:text-zinc-400 font-mono">Call Hash: {{ audioCall.hash }}</div>
</div>
<div class="hidden group-hover:flex space-x-2 ml-auto my-auto mx-2">
<div class="hidden group-hover:flex items-center gap-2 ml-auto flex-shrink-0">
<!-- delete call -->
<button @click="deleteCall(audioCall.hash)" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full bg-gray-100 p-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
<button @click="deleteCall(audioCall.hash)" type="button" class="inline-flex items-center justify-center gap-x-1.5 rounded-xl bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 px-3 py-2 text-sm font-semibold text-gray-700 dark:text-zinc-300 shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
@@ -259,6 +254,7 @@
<script>
import protobuf from "protobufjs";
import DialogUtils from "../../js/DialogUtils";
export default {
name: 'CallPage',
data() {
@@ -488,7 +484,7 @@ export default {
async hangupCall(callHash) {
// confirm user wants to hang up call
if(!confirm("Are you sure you want to hang up this call?")){
if(!await DialogUtils.confirm("Are you sure you want to hang up this call?")){
return;
}
@@ -681,7 +677,7 @@ export default {
async deleteCall(callHash) {
// confirm user wants to delete call
if(!confirm("Are you sure you want to delete this call?")){
if(!await DialogUtils.confirm("Are you sure you want to delete this call?")){
return;
}
@@ -701,7 +697,7 @@ export default {
async clearCallHistory() {
// confirm user wants to clear call history
if(!confirm("Are you sure you want to clear your call history?")){
if(!await DialogUtils.confirm("Are you sure you want to clear your call history?")){
return;
}

View File

@@ -0,0 +1,10 @@
<template>
<label class="block text-sm font-medium text-gray-900 dark:text-zinc-100">
<slot/>
</label>
</template>
<script>
export default {
name: 'FormLabel',
}
</script>

View File

@@ -0,0 +1,10 @@
<template>
<div class="text-xs text-gray-600 dark:text-zinc-300">
<slot/>
</div>
</template>
<script>
export default {
name: 'FormSubLabel',
}
</script>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
<template>
<div class="bg-white rounded shadow divide-y divide-gray-300 dark:divide-zinc-700 dark:bg-zinc-900 overflow-hidden">
<div @click="isExpanded = !isExpanded" class="flex p-2 justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800">
<div class="my-auto mr-auto">
<div class="font-bold dark:text-white">
<slot name="title"/>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
<slot name="subtitle"/>
</div>
</div>
<div class="my-auto ml-2">
<div class="w-5 h-5 text-gray-600 dark:text-gray-300 transform transition-transform duration-200" :class="{ 'rotate-90': isExpanded }">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="size-5">
<rect width="256" height="256" fill="none"/>
<path d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34Z" fill="currentColor"/>
</svg>
</div>
</div>
</div>
<div v-if="isExpanded" class="divide-y divide-gray-200 dark:text-white">
<slot name="content"/>
</div>
</div>
</template>
<script>
export default {
name: 'ExpandingSection',
data() {
return {
isExpanded: false,
};
},
}
</script>

View File

@@ -0,0 +1,241 @@
<template>
<div v-if="isShowing" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity flex items-center justify-center">
<div class="flex w-full h-full p-4 overflow-y-auto">
<div v-click-outside="dismiss" class="my-auto mx-auto w-full bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl">
<!-- title -->
<div class="p-4 border-b dark:border-zinc-700">
<h3 class="text-lg font-semibold dark:text-white">Import Interfaces</h3>
</div>
<!-- content -->
<div class="divide-y dark:divide-zinc-700">
<!-- file input -->
<div class="p-2">
<div>
<input ref="import-interfaces-file-input" type="file" @change="onFileSelected" accept="*" class="w-full text-sm text-gray-500 dark:text-zinc-400">
</div>
<div v-if="!selectedFile" class="mt-2 text-sm text-gray-700 dark:text-zinc-200">
<ul class="list-disc list-inside">
<li>You can import interfaces from a ~/.reticulum/config file.</li>
<li>You can import interfaces from an exported interfaces file.</li>
</ul>
</div>
</div>
<!-- select interfaces -->
<div v-if="importableInterfaces.length > 0" class="divide-y dark:divide-zinc-700">
<div class="flex p-2">
<div class="my-auto mr-auto text-sm font-medium text-gray-700 dark:text-zinc-200">Select Interfaces to Import</div>
<div class="my-auto space-x-2">
<button @click="selectAllInterfaces" class="text-sm text-blue-500 hover:underline">Select All</button>
<button @click="deselectAllInterfaces" class="text-sm text-blue-500 hover:underline">Deselect All</button>
</div>
</div>
<div class="bg-gray-200 p-2 space-y-2 max-h-80 overflow-y-auto dark:bg-zinc-800">
<div @click="toggleSelectedInterface(iface.name)" v-for="iface in importableInterfaces" :key="iface.name" class="bg-white cursor-pointer flex items-center p-2 border rounded shadow dark:bg-zinc-900 dark:border-zinc-700">
<div class="mr-auto text-sm">
<div class="font-semibold text-gray-700 dark:text-zinc-100">{{ iface.name }}</div>
<div class="text-sm text-gray-500 dark:text-zinc-100">
<!-- auto interface -->
<div v-if="iface.type === 'AutoInterface'">
<div>{{ iface.type }}</div>
<div>Ethernet and WiFi</div>
</div>
<!-- tcp client interface -->
<div v-else-if="iface.type === 'TCPClientInterface'">
<div>{{ iface.type }}</div>
<div>{{ iface.target_host }}:{{ iface.target_port }}</div>
</div>
<!-- tcp server interface -->
<div v-else-if="iface.type === 'TCPServerInterface'">
<div>{{ iface.type }}</div>
<div>{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
</div>
<!-- udp interface -->
<div v-else-if="iface.type === 'UDPInterface'">
<div>{{ iface.type }}</div>
<div>Listen: {{ iface.listen_ip }}:{{ iface.listen_port }}</div>
<div>Forward: {{ iface.forward_ip }}:{{ iface.forward_port }}</div>
</div>
<!-- rnode interface details -->
<div v-else-if="iface.type === 'RNodeInterface'">
<div>{{ iface.type }}</div>
<div>Port: {{ iface.port }}</div>
<div>Frequency: {{ formatFrequency(iface.frequency) }}</div>
<div>Bandwidth: {{ formatFrequency(iface.bandwidth) }}</div>
<div>Spreading Factor: {{ iface.spreadingfactor }}</div>
<div>Coding Rate: {{ iface.codingrate }}</div>
<div>Transmit Power: {{ iface.txpower }}dBm</div>
</div>
<!-- other interface types -->
<div v-else>{{ iface.type }}</div>
</div>
</div>
<input @click.stop type="checkbox" v-model="selectedInterfaces" :value="iface.name" class="mx-2 h-4 w-4 text-blue-600 rounded border-gray-300 dark:border-zinc-600">
</div>
</div>
</div>
</div>
<!-- actions -->
<div class="p-4 border-t dark:border-zinc-700 flex justify-end space-x-2">
<button @click="dismiss" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-zinc-800 dark:text-zinc-200 dark:border-zinc-600 dark:hover:bg-zinc-700">
Cancel
</button>
<button @click="importSelectedInterfaces" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600">
Import Selected
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import DialogUtils from "../../js/DialogUtils";
import Utils from "../../js/Utils";
export default {
name: "ImportInterfacesModal",
emits: [
"dismissed",
],
data() {
return {
isShowing: false,
selectedFile: null,
importableInterfaces: [],
selectedInterfaces: [],
};
},
methods: {
show() {
this.isShowing = true;
this.selectedFile = null;
this.importableInterfaces = [];
this.selectedInterfaces = [];
},
dismiss(result = false) {
this.isShowing = false;
const imported = result === true;
this.$emit("dismissed", imported);
},
clearSelectedFile() {
this.selectedFile = null;
this.$refs["import-interfaces-file-input"].value = null;
},
async onFileSelected(event) {
// get selected file
const file = event.target.files[0];
if(!file){
return;
}
// update ui
this.selectedFile = file;
this.importableInterfaces = [];
this.selectedInterfaces = [];
try {
// fetch preview of interfaces to import
const response = await window.axios.post('/api/v1/reticulum/interfaces/import-preview', {
config: await file.text(),
});
// ensure there are some interfaces available to import
if(!response.data.interfaces || response.data.interfaces.length === 0){
this.clearSelectedFile();
DialogUtils.alert("No interfaces were found in the selected configuration file");
return;
}
// update ui
this.importableInterfaces = response.data.interfaces;
// auto select all interfaces
this.selectAllInterfaces();
} catch(e) {
this.clearSelectedFile();
DialogUtils.alert("Failed to parse configuration file");
console.error(e);
}
},
isInterfaceSelected(name) {
return this.selectedInterfaces.includes(name);
},
selectInterface(name) {
if(!this.isInterfaceSelected(name)){
this.selectedInterfaces.push(name);
}
},
deselectInterface(name) {
this.selectedInterfaces = this.selectedInterfaces.filter((selectedInterfaceName) => {
return selectedInterfaceName !== name;
});
},
toggleSelectedInterface(name) {
if(this.isInterfaceSelected(name)){
this.deselectInterface(name);
} else {
this.selectInterface(name);
}
},
selectAllInterfaces() {
this.selectedInterfaces = this.importableInterfaces.map(i => i.name);
},
deselectAllInterfaces() {
this.selectedInterfaces = [];
},
async importSelectedInterfaces() {
// ensure user selected a file to import from
if(!this.selectedFile){
DialogUtils.alert("Please select a configuration file");
return;
}
// ensure user selected some interfaces
if(this.selectedInterfaces.length === 0){
DialogUtils.alert("Please select at least one interface to import");
return;
}
try {
// import interfaces
await window.axios.post('/api/v1/reticulum/interfaces/import', {
config: await this.selectedFile.text(),
selected_interface_names: this.selectedInterfaces,
});
// dismiss modal
this.dismiss(true);
// tell user interfaces were imported
DialogUtils.alert("Interfaces imported successfully. MeshChat must be restarted for these changes to take effect.");
} catch(e) {
const message = e.response?.data?.message || "Failed to import interfaces";
DialogUtils.alert(message);
console.error(e);
}
},
formatFrequency(hz) {
return Utils.formatFrequency(hz);
},
},
}
</script>

View File

@@ -0,0 +1,251 @@
<template>
<div class="interface-card">
<div class="flex gap-4 items-start">
<div class="interface-card__icon">
<MaterialDesignIcon :icon-name="iconName" class="w-6 h-6"/>
</div>
<div class="flex-1 space-y-2">
<div class="flex items-center gap-2 flex-wrap">
<div class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ iface._name }}</div>
<span class="type-chip">{{ iface.type }}</span>
<span :class="statusChipClass">{{ isInterfaceEnabled(iface) ? 'Enabled' : 'Disabled' }}</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ description }}
</div>
<div class="flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
<span class="stat-chip" v-if="iface._stats?.bitrate">Bitrate {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</span>
<span class="stat-chip">TX {{ formatBytes(iface._stats?.txb ?? 0) }}</span>
<span class="stat-chip">RX {{ formatBytes(iface._stats?.rxb ?? 0) }}</span>
<span class="stat-chip" v-if="iface.type === 'RNodeInterface' && iface._stats?.noise_floor">Noise {{ iface._stats?.noise_floor }} dBm</span>
<span class="stat-chip" v-if="iface._stats?.clients != null">Clients {{ iface._stats?.clients }}</span>
</div>
<div v-if="iface._stats?.ifac_signature" class="ifac-line">
<span class="text-emerald-500 font-semibold">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span>
<span v-if="iface._stats?.ifac_netname"> {{ iface._stats.ifac_netname }}</span>
<span></span>
<button @click="onIFACSignatureClick(iface._stats.ifac_signature)" type="button" class="text-blue-500 hover:underline">
{{ iface._stats.ifac_signature.slice(0, 8) }}{{ iface._stats.ifac_signature.slice(-8) }}
</button>
</div>
</div>
<div class="flex flex-col gap-2 items-end">
<button
v-if="isInterfaceEnabled(iface)"
@click="disableInterface"
type="button"
class="secondary-chip text-xs"
>
<MaterialDesignIcon icon-name="power" class="w-4 h-4"/>
Disable
</button>
<button
v-else
@click="enableInterface"
type="button"
class="primary-chip text-xs"
>
<MaterialDesignIcon icon-name="power" class="w-4 h-4"/>
Enable
</button>
<DropDownMenu>
<template #button>
<IconButton>
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5"/>
</IconButton>
</template>
<template #items>
<div class="max-h-60 overflow-auto py-1 space-y-1 pr-1">
<DropDownMenuItem @click="editInterface">
<MaterialDesignIcon icon-name="pencil" class="w-5 h-5"/>
<span>Edit Interface</span>
</DropDownMenuItem>
<DropDownMenuItem @click="exportInterface">
<MaterialDesignIcon icon-name="export" class="w-5 h-5"/>
<span>Export Interface</span>
</DropDownMenuItem>
<DropDownMenuItem @click="deleteInterface">
<MaterialDesignIcon icon-name="trash-can" class="w-5 h-5 text-red-500"/>
<span class="text-red-500">Delete Interface</span>
</DropDownMenuItem>
</div>
</template>
</DropDownMenu>
</div>
</div>
<div v-if="['UDPInterface', 'RNodeInterface'].includes(iface.type)" class="mt-4 grid gap-2 text-sm text-gray-700 dark:text-gray-300">
<div v-if="iface.type === 'UDPInterface'" class="detail-grid">
<div>
<div class="detail-label">Listen</div>
<div class="detail-value">{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
</div>
<div>
<div class="detail-label">Forward</div>
<div class="detail-value">{{ iface.forward_ip }}:{{ iface.forward_port }}</div>
</div>
</div>
<div v-else-if="iface.type === 'RNodeInterface'" class="detail-grid">
<div>
<div class="detail-label">Port</div>
<div class="detail-value">{{ iface.port }}</div>
</div>
<div>
<div class="detail-label">Frequency</div>
<div class="detail-value">{{ formatFrequency(iface.frequency) }}</div>
</div>
<div>
<div class="detail-label">Bandwidth</div>
<div class="detail-value">{{ formatFrequency(iface.bandwidth) }}</div>
</div>
<div>
<div class="detail-label">Spreading Factor</div>
<div class="detail-value">{{ iface.spreadingfactor }}</div>
</div>
<div>
<div class="detail-label">Coding Rate</div>
<div class="detail-value">{{ iface.codingrate }}</div>
</div>
<div>
<div class="detail-label">TX Power</div>
<div class="detail-value">{{ iface.txpower }} dBm</div>
</div>
</div>
</div>
</div>
</template>
<script>
import DialogUtils from "../../js/DialogUtils";
import Utils from "../../js/Utils";
import DropDownMenuItem from "../DropDownMenuItem.vue";
import IconButton from "../IconButton.vue";
import DropDownMenu from "../DropDownMenu.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: 'Interface',
components: {
DropDownMenu,
IconButton,
DropDownMenuItem,
MaterialDesignIcon,
},
props: {
iface: Object,
},
data() {
return {
};
},
methods: {
onIFACSignatureClick: function(ifacSignature) {
DialogUtils.alert(ifacSignature);
},
isInterfaceEnabled: function(iface) {
return Utils.isInterfaceEnabled(iface);
},
enableInterface() {
this.$emit("enable");
},
disableInterface() {
this.$emit("disable");
},
editInterface() {
this.$emit("edit");
},
exportInterface() {
this.$emit("export");
},
deleteInterface() {
this.$emit("delete");
},
formatBitsPerSecond: function(bits) {
return Utils.formatBitsPerSecond(bits);
},
formatBytes: function(bytes) {
return Utils.formatBytes(bytes);
},
formatFrequency(hz) {
return Utils.formatFrequency(hz);
},
},
computed: {
iconName() {
switch (this.iface.type) {
case "AutoInterface":
return "home-automation";
case "RNodeInterface":
return "radio-tower";
case "RNodeMultiInterface":
return "access-point-network";
case "TCPClientInterface":
return "lan-connect";
case "TCPServerInterface":
return "lan";
case "UDPInterface":
return "wan";
case "SerialInterface":
return "usb-port";
case "KISSInterface":
case "AX25KISSInterface":
return "antenna";
case "I2PInterface":
return "eye";
case "PipeInterface":
return "pipe";
default:
return "server-network";
}
},
description() {
if (this.iface.type === "TCPClientInterface") {
return `${this.iface.target_host}:${this.iface.target_port}`;
}
if (this.iface.type === "TCPServerInterface" || this.iface.type === "UDPInterface") {
return `${this.iface.listen_ip}:${this.iface.listen_port}`;
}
if (this.iface.type === "SerialInterface") {
return `${this.iface.port} @ ${this.iface.speed || "9600"}bps`;
}
if (this.iface.type === "AutoInterface") {
return "Auto-detect Ethernet and Wi-Fi peers";
}
return this.iface.description || "Custom interface";
},
statusChipClass() {
return this.isInterfaceEnabled(this.iface)
? "inline-flex items-center rounded-full bg-green-100 text-green-700 px-2 py-0.5 text-xs font-semibold"
: "inline-flex items-center rounded-full bg-red-100 text-red-700 px-2 py-0.5 text-xs font-semibold";
},
},
}
</script>
<style scoped>
.interface-card {
@apply bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg p-4 space-y-3;
}
.interface-card__icon {
@apply w-12 h-12 rounded-2xl bg-blue-50 text-blue-600 dark:bg-blue-900/40 dark:text-blue-200 flex items-center justify-center;
}
.type-chip {
@apply inline-flex items-center rounded-full bg-gray-100 dark:bg-zinc-800 px-2 py-0.5 text-xs font-semibold text-gray-600 dark:text-gray-200;
}
.stat-chip {
@apply inline-flex items-center rounded-full border border-gray-200 dark:border-zinc-700 px-2 py-0.5;
}
.ifac-line {
@apply text-xs flex flex-wrap items-center gap-1;
}
.detail-grid {
@apply grid gap-3 sm:grid-cols-2;
}
.detail-label {
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
}
.detail-value {
@apply text-sm font-medium text-gray-900 dark:text-white;
}
</style>

View File

@@ -0,0 +1,340 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900">
<div class="overflow-y-auto p-3 md:p-6 space-y-4 max-w-6xl mx-auto w-full">
<div v-if="showRestartReminder" class="bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-3xl shadow-xl p-4 flex flex-wrap gap-3 items-center">
<div class="flex items-center gap-3">
<MaterialDesignIcon icon-name="alert" class="w-6 h-6"/>
<div>
<div class="text-lg font-semibold">Restart required</div>
<div class="text-sm">Reticulum MeshChat must be restarted for any interface changes to take effect.</div>
</div>
</div>
<button v-if="isElectron" @click="relaunch" type="button" class="ml-auto inline-flex items-center gap-2 rounded-full border border-white/40 px-4 py-1.5 text-sm font-semibold text-white hover:bg-white/10 transition">
<MaterialDesignIcon icon-name="restart" class="w-4 h-4"/>
Restart now
</button>
</div>
<div class="glass-card space-y-4">
<div class="flex flex-wrap gap-3 items-center">
<div class="flex-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Manage</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
<div class="text-sm text-gray-600 dark:text-gray-300">Search, filter and export your Reticulum adapters.</div>
</div>
<div class="flex flex-wrap gap-2">
<RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
<MaterialDesignIcon icon-name="plus" class="w-4 h-4"/>
Add Interface
</RouterLink>
<button @click="showImportInterfacesModal" type="button" class="secondary-chip text-sm">
<MaterialDesignIcon icon-name="import" class="w-4 h-4"/>
Import
</button>
<button @click="exportInterfaces" type="button" class="secondary-chip text-sm">
<MaterialDesignIcon icon-name="export" class="w-4 h-4"/>
Export all
</button>
</div>
</div>
<div class="flex flex-wrap gap-3 items-center">
<div class="flex-1">
<input
v-model="searchTerm"
type="text"
placeholder="Search by name, type, host..."
class="input-field"
/>
</div>
<div class="flex gap-2 flex-wrap">
<button type="button" @click="setStatusFilter('all')" :class="filterChipClass(statusFilter === 'all')">All</button>
<button type="button" @click="setStatusFilter('enabled')" :class="filterChipClass(statusFilter === 'enabled')">Enabled</button>
<button type="button" @click="setStatusFilter('disabled')" :class="filterChipClass(statusFilter === 'disabled')">Disabled</button>
</div>
<div class="w-full sm:w-60">
<select v-model="typeFilter" class="input-field">
<option value="all">All types</option>
<option v-for="type in sortedInterfaceTypes" :key="type" :value="type">{{ type }}</option>
</select>
</div>
</div>
</div>
<div v-if="filteredInterfaces.length === 0" class="glass-card text-center py-10 text-gray-500 dark:text-gray-300">
<MaterialDesignIcon icon-name="lan-disconnect" class="w-10 h-10 mx-auto mb-3"/>
<div class="text-lg font-semibold">No interfaces found</div>
<div class="text-sm">Adjust your search or add a new interface.</div>
</div>
<div v-else class="grid gap-4 xl:grid-cols-2">
<Interface
v-for="iface of filteredInterfaces"
:key="iface._name"
:iface="iface"
@enable="enableInterface(iface._name)"
@disable="disableInterface(iface._name)"
@edit="editInterface(iface._name)"
@export="exportInterface(iface._name)"
@delete="deleteInterface(iface._name)"/>
</div>
</div>
</div>
<ImportInterfacesModal ref="import-interfaces-modal" @dismissed="onImportInterfacesModalDismissed"/>
</template>
<script>
import DialogUtils from "../../js/DialogUtils";
import ElectronUtils from "../../js/ElectronUtils";
import Interface from "./Interface.vue";
import Utils from "../../js/Utils";
import ImportInterfacesModal from "./ImportInterfacesModal.vue";
import DownloadUtils from "../../js/DownloadUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: 'InterfacesPage',
components: {
ImportInterfacesModal,
Interface,
MaterialDesignIcon,
},
data() {
return {
interfaces: {},
interfaceStats: {},
reloadInterval: null,
searchTerm: "",
statusFilter: "all",
typeFilter: "all",
hasPendingInterfaceChanges: false,
};
},
beforeUnmount() {
clearInterval(this.reloadInterval);
},
mounted() {
this.loadInterfaces();
this.updateInterfaceStats();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.updateInterfaceStats();
}, 1000);
},
methods: {
relaunch() {
ElectronUtils.relaunch();
},
trackInterfaceChange() {
this.hasPendingInterfaceChanges = true;
},
isInterfaceEnabled: function(iface) {
return Utils.isInterfaceEnabled(iface);
},
async loadInterfaces() {
try {
const response = await window.axios.get(`/api/v1/reticulum/interfaces`);
this.interfaces = response.data.interfaces;
} catch(e) {
// do nothing if failed to load interfaces
}
},
async updateInterfaceStats() {
try {
// fetch interface stats
const response = await window.axios.get(`/api/v1/interface-stats`);
// update data
const interfaces = response.data.interface_stats?.interfaces ?? [];
for(const iface of interfaces){
this.interfaceStats[iface.short_name] = iface;
}
} catch(e) {
// do nothing if failed to load interfaces
}
},
async enableInterface(interfaceName) {
// enable interface
try {
await window.axios.post(`/api/v1/reticulum/interfaces/enable`, {
name: interfaceName,
});
this.trackInterfaceChange();
} catch(e) {
DialogUtils.alert("failed to enable interface");
console.log(e);
}
// reload interfaces
await this.loadInterfaces();
},
async disableInterface(interfaceName) {
// disable interface
try {
await window.axios.post(`/api/v1/reticulum/interfaces/disable`, {
name: interfaceName,
});
this.trackInterfaceChange();
} catch(e) {
DialogUtils.alert("failed to disable interface");
console.log(e);
}
// reload interfaces
await this.loadInterfaces();
},
async editInterface(interfaceName) {
this.$router.push({
name: "interfaces.edit",
query: {
interface_name: interfaceName,
},
});
},
async deleteInterface(interfaceName) {
// ask user to confirm deleting conversation history
if(!await DialogUtils.confirm("Are you sure you want to delete this interface? This can not be undone!")){
return;
}
// delete interface
try {
await window.axios.post(`/api/v1/reticulum/interfaces/delete`, {
name: interfaceName,
});
this.trackInterfaceChange();
} catch(e) {
DialogUtils.alert("failed to delete interface");
console.log(e);
}
// reload interfaces
await this.loadInterfaces();
},
async exportInterfaces() {
try {
// fetch exported interfaces
const response = await window.axios.post('/api/v1/reticulum/interfaces/export');
this.trackInterfaceChange();
// download file to browser
DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
} catch(e) {
DialogUtils.alert("Failed to export interfaces");
console.error(e);
}
},
async exportInterface(interfaceName) {
try {
// fetch exported interfaces
const response = await window.axios.post('/api/v1/reticulum/interfaces/export', {
selected_interface_names: [
interfaceName,
],
});
this.trackInterfaceChange();
// download file to browser
DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
} catch(e) {
DialogUtils.alert("Failed to export interface");
console.error(e);
}
},
showImportInterfacesModal() {
this.$refs["import-interfaces-modal"].show();
},
onImportInterfacesModalDismissed(imported = false) {
// reload interfaces as something may have been imported
this.loadInterfaces();
if(imported){
this.trackInterfaceChange();
}
},
setStatusFilter(value) {
this.statusFilter = value;
},
filterChipClass(isActive) {
return isActive
? "primary-chip text-xs"
: "secondary-chip text-xs";
},
},
computed: {
isElectron() {
return ElectronUtils.isElectron();
},
showRestartReminder() {
return this.hasPendingInterfaceChanges;
},
interfacesWithStats() {
const results = [];
for(const [interfaceName, iface] of Object.entries(this.interfaces)){
iface._name = interfaceName;
iface._stats = this.interfaceStats[interfaceName];
results.push(iface);
}
return results;
},
enabledInterfaces() {
return this.interfacesWithStats.filter((iface) => this.isInterfaceEnabled(iface));
},
disabledInterfaces() {
return this.interfacesWithStats.filter((iface) => !this.isInterfaceEnabled(iface));
},
filteredInterfaces() {
const search = this.searchTerm.toLowerCase().trim();
return this.interfacesWithStats
.filter((iface) => {
if (this.statusFilter === "enabled" && !this.isInterfaceEnabled(iface)) {
return false;
}
if (this.statusFilter === "disabled" && this.isInterfaceEnabled(iface)) {
return false;
}
if (this.typeFilter !== "all" && iface.type !== this.typeFilter) {
return false;
}
if (!search) {
return true;
}
const haystack = [
iface._name,
iface.type,
iface.target_host,
iface.target_port,
iface.listen_ip,
iface.listen_port,
].filter(Boolean).join(" ").toLowerCase();
return haystack.includes(search);
})
.sort((a, b) => {
const enabledDiff = Number(this.isInterfaceEnabled(b)) - Number(this.isInterfaceEnabled(a));
if (enabledDiff !== 0) return enabledDiff;
return a._name.localeCompare(b._name);
});
},
sortedInterfaceTypes() {
const types = new Set();
this.interfacesWithStats.forEach((iface) => types.add(iface.type));
return Array.from(types).sort();
},
},
}
</script>

View File

@@ -1,22 +1,16 @@
<template>
<div class="inline-flex rounded-md shadow-sm">
<div class="inline-flex">
<button v-if="isRecordingAudioAttachment" @click="stopRecordingAudioAttachment" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
<path d="M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z" />
</svg>
<button v-if="isRecordingAudioAttachment" @click="stopRecordingAudioAttachment" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 shadow-sm hover:border-red-400 transition dark:border-red-500/40 dark:bg-red-900/30 dark:text-red-100">
<MaterialDesignIcon icon-name="microphone" class="w-4 h-4"/>
<span class="ml-1">
<slot/>
</span>
</button>
<button v-else @click="showMenu" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
<path d="M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z" />
</svg>
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Voice</span>
<button v-else @click="showMenu" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition">
<MaterialDesignIcon icon-name="microphone-plus" class="w-4 h-4"/>
<span class="hidden xl:inline-block whitespace-nowrap">Add Voice</span>
</button>
<div class="relative block">
@@ -27,11 +21,11 @@
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95">
<div v-if="isShowingMenu" v-click-outside="hideMenu" class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div v-if="isShowingMenu" v-click-outside="hideMenu" class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none">
<div class="py-1">
<button @click="startRecordingCodec2('1200')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Low Quality - Codec2 (1200)</button>
<button @click="startRecordingCodec2('3200')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Medium Quality - Codec2 (3200)</button>
<button @click="startRecordingOpus()" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">High Quality - OPUS</button>
<button @click="startRecordingCodec2('1200')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Low Quality - Codec2 (1200)</button>
<button @click="startRecordingCodec2('3200')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Medium Quality - Codec2 (3200)</button>
<button @click="startRecordingOpus()" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">High Quality - OPUS</button>
</div>
</div>
</Transition>
@@ -41,8 +35,12 @@
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: 'AddAudioButton',
components: {
MaterialDesignIcon,
},
props: {
isRecordingAudioAttachment: Boolean,
},

View File

@@ -1,11 +1,9 @@
<template>
<div class="inline-flex rounded-md shadow-sm">
<div class="inline-flex">
<button @click="showMenu" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M1.5 6a2.25 2.25 0 0 1 2.25-2.25h16.5A2.25 2.25 0 0 1 22.5 6v12a2.25 2.25 0 0 1-2.25 2.25H3.75A2.25 2.25 0 0 1 1.5 18V6ZM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0 0 21 18v-1.94l-2.69-2.689a1.5 1.5 0 0 0-2.12 0l-.88.879.97.97a.75.75 0 1 1-1.06 1.06l-5.16-5.159a1.5 1.5 0 0 0-2.12 0L3 16.061Zm10.125-7.81a1.125 1.125 0 1 1 2.25 0 1.125 1.125 0 0 1-2.25 0Z" clip-rule="evenodd" />
</svg>
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Image</span>
<button @click="showMenu" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition">
<MaterialDesignIcon icon-name="image-plus" class="w-4 h-4"/>
<span class="hidden xl:inline-block whitespace-nowrap">Add Image</span>
</button>
<div class="relative block">
@@ -16,12 +14,12 @@
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95">
<div v-if="isShowingMenu" v-click-outside="hideMenu" class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div v-if="isShowingMenu" v-click-outside="hideMenu" class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none">
<div class="py-1">
<button @click="addImage('low')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Low Quality (320x320)</button>
<button @click="addImage('medium')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Medium Quality (640x640)</button>
<button @click="addImage('high')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">High Quality (1280x1280)</button>
<button @click="addImage('original')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 whitespace-nowrap">Original Quality</button>
<button @click="addImage('low')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Low Quality (320x320)</button>
<button @click="addImage('medium')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Medium Quality (640x640)</button>
<button @click="addImage('high')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">High Quality (1280x1280)</button>
<button @click="addImage('original')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Original Quality</button>
</div>
</div>
</Transition>
@@ -36,8 +34,12 @@
<script>
import Compressor from 'compressorjs';
import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: 'AddImageButton',
components: {
MaterialDesignIcon,
},
emits: [
"add-image",
],

View File

@@ -35,6 +35,20 @@
<span>Set Custom Display Name</span>
</DropDownMenuItem>
<!-- block/unblock button -->
<div class="border-t">
<DropDownMenuItem v-if="!isBlocked" @click="onBlockDestination">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5 text-red-500">
<path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3V13.5a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm-1.5 8.25v3a1.5 1.5 0 0 0 3 0v-3a1.5 1.5 0 0 0-3 0Z" clip-rule="evenodd" />
</svg>
<span class="text-red-500">Block User</span>
</DropDownMenuItem>
<DropDownMenuItem v-else @click="onUnblockDestination">
<MaterialDesignIcon icon-name="check-circle" class="size-5 text-green-500"/>
<span class="text-green-500">Unblock User</span>
</DropDownMenuItem>
</div>
<!-- delete message history button -->
<div class="border-t">
<DropDownMenuItem @click="onDeleteMessageHistory">
@@ -53,6 +67,7 @@
import DropDownMenu from "../DropDownMenu.vue";
import DropDownMenuItem from "../DropDownMenuItem.vue";
import IconButton from "../IconButton.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DialogUtils from "../../js/DialogUtils";
export default {
@@ -61,6 +76,7 @@ export default {
IconButton,
DropDownMenuItem,
DropDownMenu,
MaterialDesignIcon,
},
props: {
peer: Object,
@@ -68,12 +84,76 @@ export default {
emits: [
"conversation-deleted",
"set-custom-display-name",
"block-status-changed",
],
data() {
return {
isBlocked: false,
blockedDestinations: [],
};
},
async mounted() {
await this.loadBlockedDestinations();
},
watch: {
peer: {
handler() {
this.checkIfBlocked();
},
immediate: true,
},
},
methods: {
async loadBlockedDestinations() {
try {
const response = await window.axios.get("/api/v1/blocked-destinations");
this.blockedDestinations = response.data.blocked_destinations || [];
this.checkIfBlocked();
} catch(e) {
console.log(e);
}
},
checkIfBlocked() {
if (!this.peer) {
this.isBlocked = false;
return;
}
this.isBlocked = this.blockedDestinations.some(
b => b.destination_hash === this.peer.destination_hash
);
},
async onBlockDestination() {
if (!await DialogUtils.confirm("Are you sure you want to block this user? They will not be able to send you messages or establish links.")) {
return;
}
try {
await window.axios.post("/api/v1/blocked-destinations", {
destination_hash: this.peer.destination_hash,
});
await this.loadBlockedDestinations();
DialogUtils.alert("User blocked successfully");
this.$emit("block-status-changed");
} catch(e) {
DialogUtils.alert("Failed to block user");
console.log(e);
}
},
async onUnblockDestination() {
try {
await window.axios.delete(`/api/v1/blocked-destinations/${this.peer.destination_hash}`);
await this.loadBlockedDestinations();
DialogUtils.alert("User unblocked successfully");
this.$emit("block-status-changed");
} catch(e) {
DialogUtils.alert("Failed to unblock user");
console.log(e);
}
},
async onDeleteMessageHistory() {
// ask user to confirm deleting conversation history
if(!confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
if(!await DialogUtils.confirm("Are you sure you want to delete all messages in this conversation? This can not be undone!")){
return;
}

View File

@@ -1,33 +1,35 @@
<template>
<!-- peer selected -->
<div v-if="selectedPeer" class="flex flex-col h-full bg-white overflow-hidden sm:m-2 sm:border sm:rounded-xl sm:shadow dark:bg-zinc-950 dark:border-zinc-800">
<div v-if="selectedPeer" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-3 sm:border sm:rounded-2xl sm:shadow-lg border-gray-200/50 dark:border-zinc-800/50 transition-all">
<!-- header -->
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
<div class="flex items-center px-4 py-3 border-b border-gray-200/60 dark:border-zinc-800/60 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm">
<!-- peer icon -->
<div class="my-auto mr-2">
<div v-if="selectedPeer.lxmf_user_icon" class="p-2 rounded" :style="{ 'color': selectedPeer.lxmf_user_icon.foreground_colour, 'background-color': selectedPeer.lxmf_user_icon.background_colour }">
<div class="flex-shrink-0 mr-3">
<div v-if="selectedPeer.lxmf_user_icon" class="p-2 rounded shadow-sm" :style="{ 'color': selectedPeer.lxmf_user_icon.foreground_colour, 'background-color': selectedPeer.lxmf_user_icon.background_colour }">
<MaterialDesignIcon :icon-name="selectedPeer.lxmf_user_icon.icon_name" class="w-6 h-6"/>
</div>
<div v-else class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded">
<div v-else class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded shadow-sm">
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
</div>
</div>
<!-- peer info -->
<div>
<div @click="updateCustomDisplayName" class="flex cursor-pointer">
<div v-if="selectedPeer.custom_display_name != null" class="my-auto mr-1 dark:text-white" title="Custom Display Name">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<div class="min-w-0 flex-1">
<div @click="updateCustomDisplayName" class="flex items-center cursor-pointer min-w-0 group">
<div v-if="selectedPeer.custom_display_name != null" class="mr-1.5 text-gray-500 dark:text-zinc-400 group-hover:text-gray-700 dark:group-hover:text-zinc-200 transition-colors" title="Custom Display Name">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
</svg>
</div>
<div class="my-auto font-semibold dark:text-white" :title="selectedPeer.display_name">{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}</div>
<div class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-xs sm:max-w-sm text-base" :title="selectedPeer.custom_display_name ?? selectedPeer.display_name">
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
</div>
</div>
<div class="text-sm dark:text-zinc-300">
<div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
<!-- destination hash -->
<div class="inline-block mr-1">
@@ -62,61 +64,83 @@
</div>
<!-- dropdown menu -->
<div class="ml-auto my-auto mx-2">
<div class="ml-auto flex items-center gap-1">
<ConversationDropDownMenu
v-if="selectedPeer"
:peer="selectedPeer"
@conversation-deleted="onConversationDeleted"
@set-custom-display-name="updateCustomDisplayName"/>
</div>
@set-custom-display-name="updateCustomDisplayName"
@block-status-changed="loadBlockedDestinations"/>
<!-- popout button -->
<IconButton @click="openConversationPopout" title="Pop out chat" class="text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-200">
<MaterialDesignIcon icon-name="open-in-new" class="w-4 h-4"/>
</IconButton>
<!-- close button -->
<div class="my-auto mr-2">
<div @click="close" class="cursor-pointer">
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</div>
</div>
</div>
<!-- close button -->
<IconButton @click="close" class="text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-200">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</IconButton>
</div>
</div>
<!-- chat items -->
<div @scroll="onMessagesScroll" id="messages" class="h-full overflow-y-scroll">
<div @scroll="onMessagesScroll" id="messages" class="h-full overflow-y-scroll bg-gray-50/30 dark:bg-zinc-950/50">
<div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse p-3">
<div v-if="selectedPeerChatItems.length > 0" class="flex flex-col flex-col-reverse px-4 py-6">
<div v-for="chatItem of selectedPeerChatItemsReversed" :key="chatItem.lxmf_message.hash" class="flex flex-col max-w-xl mt-3" :class="{ 'ml-auto pl-4 md:pl-16 items-end': chatItem.is_outbound, 'mr-auto pr-4 md:pr-16 items-start': !chatItem.is_outbound }">
<div v-for="chatItem of selectedPeerChatItemsReversed" :key="chatItem.lxmf_message.hash" class="flex flex-col max-w-[75%] sm:max-w-[65%] lg:max-w-[55%] mb-4 group" :class="{ 'ml-auto items-end': chatItem.is_outbound, 'mr-auto items-start': !chatItem.is_outbound }">
<!-- message content -->
<div @click="onChatItemClick(chatItem)" class="border border-gray-300 dark:border-zinc-800 rounded-xl shadow overflow-hidden" :class="[ chatItem.lxmf_message.state === 'failed' ? 'bg-red-500 text-white' : chatItem.is_outbound ? 'bg-[#3b82f6] text-white' : 'bg-[#efefef]' ]">
<div @click="onChatItemClick(chatItem)" class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md" :class="[
['cancelled', 'failed'].includes(chatItem.lxmf_message.state)
? 'bg-red-500 text-white shadow-sm'
: chatItem.lxmf_message.is_spam
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-900 dark:text-yellow-100 border border-yellow-300 dark:border-yellow-700 shadow-sm'
: chatItem.is_outbound
? 'bg-blue-600 text-white shadow-sm'
: 'bg-white dark:bg-zinc-900 text-gray-900 dark:text-zinc-100 border border-gray-200/60 dark:border-zinc-800/60 shadow-sm'
]">
<div class="w-full space-y-0.5 px-2.5 py-1">
<div class="w-full space-y-1 px-4 py-2.5">
<!-- spam badge -->
<div v-if="chatItem.lxmf_message.is_spam" class="flex items-center gap-1.5 text-xs font-medium mb-1" :class="chatItem.is_outbound ? 'text-yellow-200' : 'text-yellow-700 dark:text-yellow-300'">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<span>Marked as Spam</span>
</div>
<!-- content -->
<div v-if="chatItem.lxmf_message.content" style="white-space:pre-wrap;word-break:break-word;font-family:inherit;">{{ chatItem.lxmf_message.content }}</div>
<div v-if="chatItem.lxmf_message.content" class="text-sm leading-relaxed whitespace-pre-wrap break-words" style="font-family:inherit;">{{ chatItem.lxmf_message.content }}</div>
<!-- image field -->
<div v-if="chatItem.lxmf_message.fields?.image">
<img @click.stop="openImage(`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`)" :src="`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`" class="w-full rounded-md cursor-pointer"/>
<div v-if="chatItem.lxmf_message.fields?.image" class="relative group mt-1 -mx-1">
<img
@click.stop="openImage(`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`)"
:src="`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`"
class="w-full rounded-lg cursor-pointer transition-transform group-hover:scale-[1.01]"/>
<div class="absolute bottom-2 left-2 bg-black/60 backdrop-blur-sm text-white text-xs px-2.5 py-1 rounded-lg flex items-center gap-1.5">
<span>{{ (chatItem.lxmf_message.fields.image.image_type ?? 'image').toUpperCase() }}</span>
<span></span>
<span>{{ formatBase64Bytes(chatItem.lxmf_message.fields.image.image_bytes) }}</span>
</div>
</div>
<!-- audio field -->
<div v-if="chatItem.lxmf_message.fields?.audio" class="pb-1">
<!-- audio is loaded -->
<audio v-if="lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" controls class="shadow rounded-full" style="height:54px;">
<source :src="lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" type="audio/wav"/>
</audio>
<audio v-if="lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]" controls class="w-full rounded-lg shadow-sm" style="height:54px;" :class="chatItem.is_outbound ? 'audio-controls-light' : 'audio-controls-dark'"></audio>
<!-- audio is not yet loaded -->
<!-- min height to make sure audio player doesn't cause height increase after loading -->
<div v-else style="min-height:54px;" class="flex">
<button @click="downloadFileFromBase64('audio.bin', chatItem.lxmf_message.fields.audio.audio_bytes)" type="button" class="my-auto flex border border-gray-300 dark:border-zinc-800 hover:bg-gray-100 rounded px-2 py-1 text-sm text-gray-700 font-semibold cursor-pointer space-x-2 bg-[#efefef]">
<button @click="downloadFileFromBase64('audio.bin', chatItem.lxmf_message.fields.audio.audio_bytes)" type="button" class="my-auto flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors" :class="chatItem.is_outbound ? 'bg-white/20 text-white border-white/20 hover:bg-white/30' : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'">
<span class="my-auto">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" />
@@ -133,17 +157,30 @@
</button>
</div>
<div class="text-xs mt-1.5" :class="chatItem.is_outbound ? 'text-white/70' : 'text-gray-500 dark:text-zinc-400'">
Audio {{ formatBase64Bytes(chatItem.lxmf_message.fields.audio.audio_bytes) }}
</div>
</div>
<!-- file attachment fields -->
<div v-if="chatItem.lxmf_message.fields?.file_attachments" class="space-y-1">
<a @click.stop target="_blank" :download="file_attachment.file_name" :href="`data:application/octet-stream;base64,${file_attachment.file_bytes}`" v-for="file_attachment of chatItem.lxmf_message.fields?.file_attachments ?? []" class="flex border border-gray-300 dark:border-zinc-800 hover:bg-gray-100 rounded px-2 py-1 text-sm text-gray-700 font-semibold cursor-pointer space-x-2 bg-[#efefef]">
<div v-if="chatItem.lxmf_message.fields?.file_attachments" class="space-y-2 mt-1">
<a
v-for="file_attachment of chatItem.lxmf_message.fields?.file_attachments ?? []"
:key="file_attachment.file_name"
@click.stop
target="_blank"
:download="file_attachment.file_name"
:href="`data:application/octet-stream;base64,${file_attachment.file_bytes}`"
class="flex items-center gap-3 border rounded-lg px-3 py-2 text-sm font-medium cursor-pointer transition-colors" :class="chatItem.is_outbound ? 'bg-white/20 text-white border-white/20 hover:bg-white/30' : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300 border-gray-200/60 dark:border-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-800'">
<div class="my-auto">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"></path>
</svg>
</div>
<div class="my-auto w-full">{{ file_attachment.file_name }}</div>
<div class="flex-1 min-w-0">
<div class="truncate">{{ file_attachment.file_name }}</div>
<div class="text-xs font-normal mt-0.5" :class="chatItem.is_outbound ? 'text-white/60' : 'text-gray-500 dark:text-zinc-400'">{{ formatBase64Bytes(file_attachment.file_bytes) }}</div>
</div>
<div class="my-auto">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
@@ -155,10 +192,10 @@
</div>
<!-- actions -->
<div v-if="chatItem.is_actions_expanded" class="border-t p-1 bg-[#efefef] text-white">
<div v-if="chatItem.is_actions_expanded" class="border-t px-4 py-2.5" :class="chatItem.is_outbound ? 'border-white/20 bg-white/10' : 'border-gray-200/60 dark:border-zinc-800/60 bg-gray-50/50 dark:bg-zinc-900/50'">
<!-- delete message -->
<button @click.stop="deleteChatItem(chatItem)" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2 py-1 text-xs font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
<button @click.stop="deleteChatItem(chatItem)" type="button" class="inline-flex items-center gap-x-1.5 rounded-lg bg-red-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-600 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
Delete
</button>
@@ -167,18 +204,19 @@
</div>
<!-- message state -->
<div v-if="chatItem.is_outbound" class="flex text-right" :class="[ chatItem.lxmf_message.state === 'failed' ? 'text-red-500' : 'text-gray-500' ]">
<div class="flex ml-auto space-x-1">
<div v-if="chatItem.is_outbound" class="flex text-right mt-1.5 px-1" :class="[ ['cancelled', 'failed'].includes(chatItem.lxmf_message.state) ? 'text-red-500 dark:text-red-400' : 'text-gray-400 dark:text-zinc-500' ]">
<div class="flex ml-auto items-center space-x-1.5 text-xs">
<!-- state label -->
<div class="my-auto">
<span @click="showSentMessageInfo(chatItem.lxmf_message)" class="space-x-1 cursor-pointer">
<span @click="toggleSentMessageInfo(chatItem.lxmf_message.hash)" class="space-x-1 cursor-pointer hover:underline">
<span>{{ chatItem.lxmf_message.state }}</span>
<span v-if="chatItem.lxmf_message.state === 'outbound' && chatItem.lxmf_message.delivery_attempts >= 1">(attempt {{ chatItem.lxmf_message.delivery_attempts + 1 }})</span>
<span v-if="chatItem.lxmf_message.state === 'sent' && chatItem.lxmf_message.method === 'opportunistic' && chatItem.lxmf_message.delivery_attempts >= 1">(attempt {{ chatItem.lxmf_message.delivery_attempts }})</span>
<span v-if="chatItem.lxmf_message.state === 'sent' && chatItem.lxmf_message.method === 'propagated'">to propagation node</span>
<span v-if="chatItem.lxmf_message.state === 'sending'">{{ chatItem.lxmf_message.progress.toFixed(0) }}%</span>
</span>
<a v-if="chatItem.lxmf_message.state === 'outbound' || chatItem.lxmf_message.state === 'sending' || chatItem.lxmf_message.state === 'sent'" @click="cancelSendingMessage(chatItem)" class="ml-1 cursor-pointer underline text-blue-500">cancel?</a>
<a v-if="chatItem.lxmf_message.state === 'failed'" @click="retrySendingMessage(chatItem)" class="ml-1 cursor-pointer underline text-blue-500">retry?</a>
</div>
@@ -189,6 +227,13 @@
</svg>
</div>
<!-- cancelled icon -->
<div v-else-if="chatItem.lxmf_message.state === 'cancelled'" class="my-auto">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-1.72 6.97a.75.75 0 1 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z" clip-rule="evenodd" />
</svg>
</div>
<!-- failed icon -->
<div v-else-if="chatItem.lxmf_message.state === 'failed'" class="my-auto">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
@@ -207,18 +252,23 @@
</div>
<!-- inbound message info -->
<div v-if="!chatItem.is_outbound" class="text-xs text-gray-500 mt-0.5 flex flex-col">
<div v-if="!chatItem.is_outbound" class="text-xs text-gray-400 dark:text-zinc-500 mt-1.5 px-1 flex flex-col">
<!-- received timestamp -->
<span @click="showReceivedMessageInfo(chatItem.lxmf_message)" class="cursor-pointer">{{ formatTimeAgo(chatItem.lxmf_message.created_at) }}</span>
<span @click="toggleReceivedMessageInfo(chatItem.lxmf_message.hash)" class="cursor-pointer hover:underline">{{ formatTimeAgo(chatItem.lxmf_message.created_at) }}</span>
</div>
<!-- expanded message details -->
<div v-if="expandedMessageInfo === chatItem.lxmf_message.hash" class="mt-2 px-1 text-xs text-gray-500 dark:text-zinc-400 space-y-0.5">
<div v-for="(line, index) in getMessageInfoLines(chatItem.lxmf_message, chatItem.is_outbound)" :key="index">{{ line }}</div>
</div>
</div>
<!-- load previous -->
<button v-show="!isLoadingPrevious && hasMorePrevious" id="load-previous" @click="loadPrevious" type="button" class="flex space-x-2 mx-auto bg-gray-200 px-3 py-1 hover:bg-gray-300 rounded-full shadow">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<button v-show="!isLoadingPrevious && hasMorePrevious" id="load-previous" @click="loadPrevious" type="button" class="flex items-center gap-2 mx-auto mt-4 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 px-4 py-2 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-full shadow-sm text-sm font-medium text-gray-700 dark:text-zinc-300 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m15 11.25-3-3m0 0-3 3m3-3v7.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span>Load Previous</span>
@@ -229,126 +279,89 @@
</div>
<!-- send message -->
<div class="w-full border-gray-300 dark:border-zinc-800 border-t p-2">
<div class="mx-auto">
<div class="w-full border-t border-gray-200/60 dark:border-zinc-800/60 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm px-3 sm:px-4 py-2.5">
<div class="w-full">
<!-- blocked user notification -->
<div v-if="isSelectedPeerBlocked" class="mb-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<span class="text-sm text-yellow-800 dark:text-yellow-200">You have blocked this user. They cannot send you messages or establish links.</span>
</div>
<!-- message composer -->
<div>
<!-- image attachment -->
<div v-if="newMessageImage" class="mb-2">
<div @click.stop="openImage(newMessageImageUrl)" class="cursor-pointer w-32 h-32 rounded shadow border relative overflow-hidden">
<!-- image preview -->
<img v-if="newMessageImageUrl" :src="newMessageImageUrl" class="w-full h-full object-cover"/>
<!-- remove button (top right) -->
<div class="absolute top-0 right-0 p-1">
<div @click.stop="removeImageAttachment" class="cursor-pointer">
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</div>
</div>
<div class="space-y-2">
<!-- image attachment -->
<div v-if="newMessageImage" class="attachment-card">
<div class="attachment-card__preview" @click.stop="openImage(newMessageImageUrl)">
<img v-if="newMessageImageUrl" :src="newMessageImageUrl" class="w-full h-full object-cover rounded-lg"/>
</div>
<!-- image size (bottom left) -->
<div class="absolute bottom-0 left-0 p-1">
<div class="bg-gray-100 rounded border text-sm px-1">{{ formatBytes(newMessageImage.size) }}</div>
<div class="attachment-card__body">
<div class="attachment-card__title">Image Attachment</div>
<div class="attachment-card__meta">{{ formatBytes(newMessageImage.size) }}</div>
</div>
<button @click.stop="removeImageAttachment" type="button" class="attachment-card__remove">
<MaterialDesignIcon icon-name="close" class="w-4 h-4"/>
</button>
</div>
</div>
<!-- audio attachment -->
<div v-if="newMessageAudio" class="mb-2">
<div class="flex flex-wrap gap-1">
<div class="flex border border-gray-300 dark:border-zinc-800 rounded text-gray-700 divide-x divide-gray-300 overflow-hidden">
<div class="flex p-1">
<!-- audio preview -->
<div>
<audio controls class="h-10">
<source :src="newMessageAudio.audio_preview_url" type="audio/wav"/>
</audio>
</div>
<!-- encoded file size -->
<div class="my-auto px-1 text-sm text-gray-500">
{{ formatBytes(newMessageAudio.audio_blob.size) }}
</div>
</div>
<!-- remove audio attachment -->
<div @click="removeAudioAttachment" class="flex my-auto text-sm text-gray-500 h-full px-1 hover:bg-gray-200 cursor-pointer">
<svg class="w-5 h-5 my-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</div>
<!-- audio attachment -->
<div v-if="newMessageAudio" class="attachment-card">
<div class="attachment-card__body w-full">
<div class="attachment-card__title">Voice Note</div>
<div class="attachment-card__meta">{{ formatBytes(newMessageAudio.audio_blob.size) }}</div>
<audio controls class="w-full mt-2 rounded-lg shadow-sm audio-controls-dark" style="height:54px;">
<source :src="newMessageAudio.audio_preview_url" type="audio/wav"/>
</audio>
</div>
<button @click="removeAudioAttachment" type="button" class="attachment-card__remove">
<MaterialDesignIcon icon-name="delete" class="w-4 h-4"/>
</button>
</div>
</div>
<!-- file attachments -->
<div v-if="newMessageFiles.length > 0" class="mb-2">
<div class="flex flex-wrap gap-1">
<div v-for="file in newMessageFiles" class="flex border border-gray-300 dark:border-zinc-800 rounded text-gray-700 divide-x divide-gray-300 overflow-hidden dark:border-zinc-800">
<div class="my-auto px-1">
<span class="mr-1">{{ file.name }}</span>
<span class="my-auto text-sm text-gray-500">{{ formatBytes(file.size) }}</span>
</div>
<div @click="removeFileAttachment(file)" class="flex my-auto text-sm text-gray-500 h-full px-1 hover:bg-gray-200 cursor-pointer">
<svg class="w-5 h-5 my-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
<!-- file attachments -->
<div v-if="newMessageFiles.length > 0" class="flex flex-wrap gap-2">
<div v-for="file in newMessageFiles" :key="file.name + file.size" class="attachment-chip">
<div class="flex items-center gap-2">
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4 text-gray-500 dark:text-gray-300"/>
<div class="text-sm text-gray-800 dark:text-gray-200 truncate max-w-[160px]">{{ file.name }}</div>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatBytes(file.size) }}</span>
</div>
<button @click="removeFileAttachment(file)" type="button" class="attachment-chip__remove">
<MaterialDesignIcon icon-name="close" class="w-3.5 h-3.5"/>
</button>
</div>
</div>
</div>
<!-- text input -->
<textarea
ref="message-input"
id="message-input"
:readonly="isSendingMessage"
v-model="newMessageText"
@keydown.enter.exact.native.prevent="onEnterPressed"
@keydown.enter.shift.exact.native.prevent="onShiftEnterPressed"
class="bg-gray-50 border border-gray-300 dark:border-zinc-800 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-800 dark:text-zinc-100 dark:border-zinc-900"
rows="3"
placeholder="Send a message..."></textarea>
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 block w-full px-3 sm:px-4 py-2 resize-none shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
rows="2"
placeholder="Type a message..."></textarea>
<!-- action button -->
<div class="flex mt-2">
<!-- add files -->
<button @click="addFilesToMessage" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM12.75 12a.75.75 0 0 0-1.5 0v2.25H9a.75.75 0 0 0 0 1.5h2.25V18a.75.75 0 0 0 1.5 0v-2.25H15a.75.75 0 0 0 0-1.5h-2.25V12Z" clip-rule="evenodd" />
<path d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z" />
</svg>
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Files</span>
<div class="flex flex-wrap gap-2 items-center mt-2">
<button @click="addFilesToMessage" type="button" class="attachment-action-button">
<MaterialDesignIcon icon-name="paperclip-plus" class="w-4 h-4"/>
<span>Add Files</span>
</button>
<!-- add image -->
<div>
<AddImageButton @add-image="onImageSelected"/>
</div>
<!-- add audio -->
<div>
<AddAudioButton
:is-recording-audio-attachment="isRecordingAudioAttachment"
@start-recording="startRecordingAudioAttachment($event)"
@stop-recording="stopRecordingAudioAttachment">
<span>Recording: {{ audioAttachmentRecordingDuration }}</span>
</AddAudioButton>
</div>
<!-- send message -->
<AddImageButton @add-image="onImageSelected"/>
<AddAudioButton
:is-recording-audio-attachment="isRecordingAudioAttachment"
@start-recording="startRecordingAudioAttachment($event)"
@stop-recording="stopRecordingAudioAttachment">
<span>Recording: {{ audioAttachmentRecordingDuration }}</span>
</AddAudioButton>
<div class="ml-auto my-auto">
<SendMessageButton
@send="sendMessage"
@@ -357,7 +370,6 @@
:can-send-message="canSendMessage"
:delivery-method="newMessageDeliveryMethod"/>
</div>
</div>
</div>
@@ -371,16 +383,53 @@
</div>
<!-- no peer selected -->
<div v-else class="flex flex-col mx-auto my-auto text-center leading-5">
<div class="mx-auto mb-1 ">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 dark:text-white">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>
<div v-else class="flex flex-col h-full items-center justify-center">
<div class="w-full max-w-md px-4">
<div class="mb-6 text-center">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-blue-100 to-blue-200 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-blue-600 dark:text-blue-400">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-zinc-100 mb-1">No Active Chat</h3>
<p class="text-sm text-gray-500 dark:text-zinc-400">Select a peer from the sidebar or enter an address below</p>
</div>
<!-- compose message input -->
<div class="w-full">
<input
ref="compose-input"
id="compose-input"
:readonly="isSendingMessage"
v-model="composeAddress"
@keydown.enter.exact.prevent="onComposeEnterPressed"
type="text"
class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
placeholder="Enter LXMF address..."/>
</div>
</div>
<div class="font-semibold dark:text-white">No Active Chat</div>
<div class='dark:text-zinc-300'>Select a Peer to start chatting!</div>
</div>
<!-- image modal -->
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0">
<div v-if="imageModalUrl" @click="closeImageModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 dark:bg-black/90 backdrop-blur-sm p-4">
<div @click.stop class="relative max-w-7xl max-h-full">
<button @click="closeImageModal" type="button" class="absolute -top-12 right-0 inline-flex items-center justify-center w-10 h-10 rounded-xl bg-white/10 dark:bg-zinc-900/10 hover:bg-white/20 dark:hover:bg-zinc-900/20 text-white transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</button>
<img :src="imageModalUrl" class="max-w-full max-h-[90vh] rounded-xl shadow-2xl" alt="Image preview"/>
</div>
</div>
</Transition>
</template>
<script>
@@ -395,10 +444,13 @@ import SendMessageButton from "./SendMessageButton.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
import AddImageButton from "./AddImageButton.vue";
import IconButton from "../IconButton.vue";
import GlobalEmitter from "../../js/GlobalEmitter";
export default {
name: 'ConversationViewer',
components: {
IconButton,
AddImageButton,
ConversationDropDownMenu,
MaterialDesignIcon,
@@ -431,6 +483,7 @@ export default {
newMessageFiles: [],
isSendingMessage: false,
autoScrollOnNewMessage: true,
composeAddress: "",
isRecordingAudioAttachment: false,
audioAttachmentMicrophoneRecorder: null,
@@ -439,6 +492,10 @@ export default {
audioAttachmentRecordingDuration: null,
audioAttachmentRecordingTimer: null,
lxmfMessageAudioAttachmentCache: {},
expandedMessageInfo: null,
imageModalUrl: null,
isSelectedPeerBlocked: false,
blockedDestinations: [],
lxmfAudioModeToCodec2ModeMap: {
// https://github.com/markqvist/LXMF/blob/master/LXMF/LXMF.py#L21
0x01: "450PWB", // AM_CODEC2_450PWB
@@ -457,14 +514,47 @@ export default {
beforeUnmount() {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
},
watch: {
selectedPeer: {
handler() {
this.checkIfSelectedPeerBlocked();
},
immediate: true,
},
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
// listen for compose new message event
GlobalEmitter.on("compose-new-message", this.onComposeNewMessageEvent);
// load blocked destinations
this.loadBlockedDestinations();
},
methods: {
async loadBlockedDestinations() {
try {
const response = await window.axios.get("/api/v1/blocked-destinations");
this.blockedDestinations = response.data.blocked_destinations || [];
this.checkIfSelectedPeerBlocked();
} catch(e) {
console.log(e);
}
},
checkIfSelectedPeerBlocked() {
if (!this.selectedPeer) {
this.isSelectedPeerBlocked = false;
return;
}
this.isSelectedPeerBlocked = this.blockedDestinations.some(
b => b.destination_hash === this.selectedPeer.destination_hash
);
},
close() {
this.$emit("close");
},
@@ -596,6 +686,40 @@ export default {
}
}
},
openLXMFAddress() {
GlobalEmitter.emit("compose-new-message");
},
onComposeNewMessageEvent(destinationHash) {
if(!this.selectedPeer && !destinationHash){
this.$nextTick(() => {
const composeInput = document.getElementById("compose-input");
if(composeInput){
composeInput.focus();
}
});
}
},
async onComposeSubmit() {
if(!this.composeAddress || this.composeAddress.trim() === ""){
return;
}
let destinationHash = this.composeAddress.trim();
this.composeAddress = "";
await this.handleComposeAddress(destinationHash);
},
onComposeEnterPressed() {
this.onComposeSubmit();
},
async handleComposeAddress(destinationHash) {
if(destinationHash.startsWith("lxmf@")){
destinationHash = destinationHash.replace("lxmf@", "");
}
if(destinationHash.length !== 32){
DialogUtils.alert("Invalid Address");
return;
}
GlobalEmitter.emit("compose-new-message", destinationHash);
},
onLxmfMessageReceived(lxmfMessage) {
// add inbound message to ui
@@ -844,16 +968,10 @@ export default {
}
},
openImage: async function(url) {
// convert data uri to blob
const blob = await (await fetch(url)).blob();
// create blob url
const fileUrl = window.URL.createObjectURL(blob);
// open new tab
window.open(fileUrl);
this.imageModalUrl = url;
},
closeImageModal() {
this.imageModalUrl = null;
},
downloadFileFromBase64: async function(fileName, fileBytesBase64) {
@@ -979,7 +1097,7 @@ export default {
try {
// ask user to confirm deleting message
if(shouldConfirm && !confirm("Are you sure you want to delete this message? This can not be undone!")){
if(shouldConfirm && !await DialogUtils.confirm("Are you sure you want to delete this message? This can not be undone!")){
return;
}
@@ -1038,7 +1156,11 @@ export default {
if(this.newMessageImage){
imageTotalSize = this.newMessageImage.size;
fields["image"] = {
// Reticulum sends image type as "jpg" or "png" and not "image/jpg" or "image/png"
// Reticulum sends image type as "jpg", "png", "webp" etc and not "image/jpg" or "image/png"
// From memory, Sideband would not display images if the image type has the "image/" prefix
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/docs/example_plugins/view.py#L78
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/main.py#L1900
// https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/ui/messages.py#L783
"image_type": this.newMessageImage.type.replace("image/", ""),
"image_bytes": Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()),
};
@@ -1060,7 +1182,7 @@ export default {
// ask user if they still want to send message if it may be rejected by sender
if(totalMessageSize > 1000 * 900){ // actual limit in LXMF Router is 1mb
if(!confirm(`Your message exceeds 900KB (It's ${this.formatBytes(totalMessageSize)}). It may be rejected by the recipient unless they have increased their delivery limit. Do you want to try sending anyway?`)){
if(!await DialogUtils.confirm(`Your message exceeds 900KB (It's ${this.formatBytes(totalMessageSize)}). It may be rejected by the recipient unless they have increased their delivery limit. Do you want to try sending anyway?`)){
return;
}
}
@@ -1106,6 +1228,38 @@ export default {
this.isSendingMessage = false;
}
},
async cancelSendingMessage(chatItem) {
// get lxmf message hash else do nothing
const lxmfMessageHash = chatItem.lxmf_message.hash;
if(!lxmfMessageHash){
return;
}
try {
// cancel sending lxmf message
const response = await window.axios.post(`/api/v1/lxmf-messages/${lxmfMessageHash}/cancel`);
// get lxmf message from response
const lxmfMessage = response.data.lxmf_message;
if(!lxmfMessage){
return;
}
// update lxmf message in ui
this.onLxmfMessageUpdated(lxmfMessage);
} catch(e) {
// show error
const message = e.response?.data?.message ?? "failed to cancel message";
DialogUtils.alert(message);
console.log(e);
}
},
async retrySendingMessage(chatItem) {
@@ -1151,6 +1305,23 @@ export default {
formatBytes: function(bytes) {
return Utils.formatBytes(bytes);
},
base64ByteLength(base64String) {
if(!base64String){
return 0;
}
const padding = (base64String.match(/=+$/) || [""])[0].length;
return Math.floor(base64String.length * 3 / 4) - padding;
},
formatBase64Bytes(base64String) {
return this.formatBytes(this.base64ByteLength(base64String));
},
openConversationPopout() {
if (!this.selectedPeer) return;
const destinationHash = this.selectedPeer.destination_hash || "";
const encodedHash = encodeURIComponent(destinationHash);
const url = `${window.location.origin}${window.location.pathname}#/popout/messages/${encodedHash}`;
window.open(url, "_blank", "width=960,height=720,noopener");
},
onFileInputChange: function(event) {
for(const file of event.target.files){
this.newMessageFiles.push(file);
@@ -1159,10 +1330,10 @@ export default {
clearFileInput: function() {
this.$refs["file-input"].value = null;
},
removeImageAttachment: function() {
async removeImageAttachment() {
// ask user to confirm removing image attachment
if(!confirm("Are you sure you want to remove this image attachment?")){
if(!await DialogUtils.confirm("Are you sure you want to remove this image attachment?")){
return;
}
@@ -1194,7 +1365,7 @@ export default {
}
// ask user to confirm recording new audio attachment, if an existing audio attachment exists
if(this.newMessageAudio && !confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
if(this.newMessageAudio && !await DialogUtils.confirm("An audio recording is already attached. A new recording will replace it. Do you want to continue?")){
return;
}
@@ -1336,10 +1507,10 @@ export default {
}
},
removeAudioAttachment: function() {
async removeAudioAttachment() {
// ask user to confirm removing audio attachment
if(!confirm("Are you sure you want to remove this audio attachment?")){
if(!await DialogUtils.confirm("Are you sure you want to remove this audio attachment?")){
return;
}
@@ -1353,7 +1524,22 @@ export default {
});
},
addNewLine: function() {
this.newMessageText += "\n";
// get cursor position for message input
const input = this.$refs["message-input"];
const cursorPosition = input.selectionStart;
// insert a newline character after the cursor position
const text = this.newMessageText;
this.newMessageText = text.slice(0, cursorPosition) + '\n' + text.slice(cursorPosition);
// move cursor to the position after the added newline
const newCursorPosition = cursorPosition + 1;
this.$nextTick(() => {
input.selectionStart = newCursorPosition;
input.selectionEnd = newCursorPosition;
});
},
onEnterPressed: function() {
@@ -1395,89 +1581,64 @@ export default {
this.$emit("reload-conversations");
},
showSentMessageInfo: function(lxmfMessage) {
// basic info
const info = [
`Created: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`,
`Method: ${lxmfMessage.method ?? "unknown"}`,
];
// add audio attachment size
if(lxmfMessage.fields?.audio?.audio_bytes){
const audioBytesLength = atob(lxmfMessage.fields?.audio?.audio_bytes).length;
info.push(`Audio Attachment: ${this.formatBytes(audioBytesLength)}`);
toggleSentMessageInfo: function(messageHash) {
if(this.expandedMessageInfo === messageHash){
this.expandedMessageInfo = null;
} else {
this.expandedMessageInfo = messageHash;
}
// add image attachment size
if(lxmfMessage.fields?.image?.image_bytes){
const imageBytesLength = atob(lxmfMessage.fields?.image?.image_bytes).length;
info.push(`Image Attachment: ${this.formatBytes(imageBytesLength)}`);
}
// add file attachments size
if(lxmfMessage.fields?.file_attachments){
var filesLength = 0;
for(const fileAttachment of lxmfMessage.fields?.file_attachments){
const fileBytesLength = atob(fileAttachment.file_bytes).length;
filesLength += fileBytesLength;
}
info.push(`File Attachments: ${this.formatBytes(filesLength)}`);
}
// show message info
DialogUtils.alert(info.join("\n"));
},
showReceivedMessageInfo: function(lxmfMessage) {
toggleReceivedMessageInfo: function(messageHash) {
if(this.expandedMessageInfo === messageHash){
this.expandedMessageInfo = null;
} else {
this.expandedMessageInfo = messageHash;
}
},
getMessageInfoLines: function(lxmfMessage, isOutbound) {
const lines = [];
// basic info
const info = [
`Sent: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`,
`Received: ${Utils.convertDateTimeToLocalDateTimeString(new Date(lxmfMessage.created_at))}`,
`Method: ${lxmfMessage.method ?? "unknown"}`,
];
if(isOutbound){
lines.push(`Created: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`);
} else {
lines.push(`Sent: ${Utils.convertUnixMillisToLocalDateTimeString(lxmfMessage.timestamp * 1000)}`);
lines.push(`Received: ${Utils.convertDateTimeToLocalDateTimeString(new Date(lxmfMessage.created_at))}`);
}
lines.push(`Method: ${lxmfMessage.method ?? "unknown"}`);
// add audio attachment size
if(lxmfMessage.fields?.audio?.audio_bytes){
const audioBytesLength = atob(lxmfMessage.fields?.audio?.audio_bytes).length;
info.push(`Audio Attachment: ${this.formatBytes(audioBytesLength)}`);
lines.push(`Audio Attachment: ${this.formatBytes(audioBytesLength)}`);
}
// add image attachment size
if(lxmfMessage.fields?.image?.image_bytes){
const imageBytesLength = atob(lxmfMessage.fields?.image?.image_bytes).length;
info.push(`Image Attachment: ${this.formatBytes(imageBytesLength)}`);
lines.push(`Image Attachment: ${this.formatBytes(imageBytesLength)}`);
}
// add file attachments size
if(lxmfMessage.fields?.file_attachments){
var filesLength = 0;
for(const fileAttachment of lxmfMessage.fields?.file_attachments){
const fileBytesLength = atob(fileAttachment.file_bytes).length;
filesLength += fileBytesLength;
}
info.push(`File Attachments: ${this.formatBytes(filesLength)}`);
lines.push(`File Attachments: ${this.formatBytes(filesLength)}`);
}
// add signal quality if available
if(lxmfMessage.quality != null){
info.push(`Signal Quality: ${lxmfMessage.quality}%`);
if(!isOutbound){
if(lxmfMessage.quality != null){
lines.push(`Signal Quality: ${lxmfMessage.quality}%`);
}
if(lxmfMessage.rssi != null){
lines.push(`RSSI: ${lxmfMessage.rssi}dBm`);
}
if(lxmfMessage.snr != null){
lines.push(`SNR: ${lxmfMessage.snr}dB`);
}
}
// add rssi if available
if(lxmfMessage.rssi != null){
info.push(`RSSI: ${lxmfMessage.rssi}dBm`);
}
// add snr if available
if(lxmfMessage.snr != null){
info.push(`SNR: ${lxmfMessage.snr}dB`);
}
// show message info
DialogUtils.alert(info.join("\n"));
return lines;
},
},
computed: {
@@ -1549,3 +1710,57 @@ export default {
},
}
</script>
<style scoped>
.attachment-card {
@apply relative flex gap-3 border border-gray-200 dark:border-zinc-800 rounded-2xl p-3 shadow-sm;
background-color: white;
}
.dark .attachment-card {
background-color: rgb(24 24 27);
}
.attachment-card__preview {
@apply w-24 h-24 overflow-hidden rounded-xl bg-gray-100 dark:bg-zinc-800 cursor-pointer;
}
.attachment-card__body {
@apply flex-1;
}
.attachment-card__title {
@apply text-sm font-semibold text-gray-800 dark:text-gray-100;
}
.attachment-card__meta {
@apply text-xs text-gray-500 dark:text-gray-400;
}
.attachment-card__remove {
@apply absolute top-2 right-2 inline-flex items-center justify-center w-6 h-6 rounded-full bg-gray-200 dark:bg-zinc-800 text-gray-600 dark:text-gray-200 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/40;
}
.attachment-chip {
@apply flex items-center justify-between gap-2 border border-gray-200 dark:border-zinc-800 rounded-full px-3 py-1 text-xs shadow-sm;
background-color: white;
}
.dark .attachment-chip {
background-color: rgb(24 24 27);
}
.attachment-chip__remove {
@apply inline-flex items-center justify-center text-gray-500 dark:text-gray-300 hover:text-red-500;
}
.attachment-action-button {
@apply inline-flex items-center gap-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition;
}
.audio-controls-light {
filter: invert(1) hue-rotate(180deg);
}
.dark .audio-controls-light {
filter: none;
}
.audio-controls-dark {
filter: none;
}
.dark .audio-controls-dark {
filter: invert(1) hue-rotate(180deg);
}
</style>

View File

@@ -0,0 +1,346 @@
<template>
<MessagesSidebar
v-if="!isPopoutMode"
:conversations="conversations"
:peers="peers"
:selected-destination-hash="selectedPeer?.destination_hash"
:conversation-search-term="conversationSearchTerm"
:filter-unread-only="filterUnreadOnly"
:filter-failed-only="filterFailedOnly"
:filter-has-attachments-only="filterHasAttachmentsOnly"
:is-loading="isLoadingConversations"
@conversation-click="onConversationClick"
@peer-click="onPeerClick"
@conversation-search-changed="onConversationSearchChanged"
@conversation-filter-changed="onConversationFilterChanged"/>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-white via-slate-50 to-slate-100 dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900/80">
<!-- messages tab -->
<ConversationViewer
ref="conversation-viewer"
:my-lxmf-address-hash="config?.lxmf_address_hash"
:selected-peer="selectedPeer"
:conversations="conversations"
@close="onCloseConversationViewer"
@reload-conversations="getConversations"/>
</div>
</template>
<script>
import WebSocketConnection from "../../js/WebSocketConnection";
import MessagesSidebar from "./MessagesSidebar.vue";
import ConversationViewer from "./ConversationViewer.vue";
import Utils from "../../js/Utils";
import GlobalState from "../../js/GlobalState";
import DialogUtils from "../../js/DialogUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
export default {
name: 'MessagesPage',
components: {
ConversationViewer,
MessagesSidebar,
},
props: {
destinationHash: String,
},
data() {
return {
reloadInterval: null,
conversationRefreshTimeout: null,
config: null,
peers: {},
selectedPeer: null,
conversations: [],
lxmfDeliveryAnnounces: [],
conversationSearchTerm: "",
filterUnreadOnly: false,
filterFailedOnly: false,
filterHasAttachmentsOnly: false,
isLoadingConversations: false,
};
},
beforeUnmount() {
clearInterval(this.reloadInterval);
clearTimeout(this.conversationRefreshTimeout);
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessage);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
GlobalEmitter.on("compose-new-message", this.onComposeNewMessage);
this.getConfig();
this.getConversations();
this.getLxmfDeliveryAnnounces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.getConversations();
}, 5000);
// compose message if a destination hash was provided on page load
if(this.destinationHash){
this.onComposeNewMessage(this.destinationHash);
}
},
methods: {
async onComposeNewMessage(destinationHash) {
if(destinationHash == null){
if(this.selectedPeer){
return;
}
this.$nextTick(() => {
const composeInput = document.getElementById("compose-input");
if(composeInput){
composeInput.focus();
}
});
return;
}
if(destinationHash.startsWith("lxmf@")){
destinationHash = destinationHash.replace("lxmf@", "");
}
await this.getLxmfDeliveryAnnounce(destinationHash);
const existingPeer = this.peers[destinationHash];
if(existingPeer){
this.onPeerClick(existingPeer);
return;
}
if(destinationHash.length !== 32){
DialogUtils.alert("Invalid Address");
return;
}
this.onPeerClick({
display_name: "Unknown Peer",
destination_hash: destinationHash,
});
},
async getConfig() {
try {
const response = await window.axios.get(`/api/v1/config`);
this.config = response.data.config;
} catch(e) {
// do nothing if failed to load config
console.log(e);
}
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
switch(json.type){
case 'config': {
this.config = json.config;
break;
}
case 'announce': {
const aspect = json.announce.aspect;
if(aspect === "lxmf.delivery"){
this.updatePeerFromAnnounce(json.announce);
}
break;
}
case 'lxmf.delivery': {
// reload conversations when a new message is received
await this.getConversations();
break;
}
}
},
async getLxmfDeliveryAnnounces() {
try {
// fetch announces for "lxmf.delivery" aspect
const response = await window.axios.get(`/api/v1/announces`, {
params: {
aspect: "lxmf.delivery",
limit: 500, // limit ui to showing 500 latest announces
},
});
// update ui
const lxmfDeliveryAnnounces = response.data.announces;
for(const lxmfDeliveryAnnounce of lxmfDeliveryAnnounces){
this.updatePeerFromAnnounce(lxmfDeliveryAnnounce);
}
} catch(e) {
// do nothing if failed to load announces
console.log(e);
}
},
async getLxmfDeliveryAnnounce(destinationHash) {
try {
// fetch announce for destination hash
const response = await window.axios.get(`/api/v1/announces`, {
params: {
destination_hash: destinationHash,
limit: 1,
},
});
// update ui
const lxmfDeliveryAnnounces = response.data.announces;
for(const lxmfDeliveryAnnounce of lxmfDeliveryAnnounces){
this.updatePeerFromAnnounce(lxmfDeliveryAnnounce);
}
} catch(e) {
// do nothing if failed to load announce
console.log(e);
}
},
async getConversations() {
try {
this.isLoadingConversations = true;
const response = await window.axios.get(`/api/v1/lxmf/conversations`, {
params: this.buildConversationQueryParams(),
});
this.conversations = response.data.conversations;
} catch(e) {
// do nothing if failed to load conversations
console.log(e);
} finally {
this.isLoadingConversations = false;
}
},
buildConversationQueryParams() {
const params = {};
if(this.conversationSearchTerm && this.conversationSearchTerm.trim() !== ""){
params.search = this.conversationSearchTerm.trim();
}
if(this.filterUnreadOnly){
params.filter_unread = true;
}
if(this.filterFailedOnly){
params.filter_failed = true;
}
if(this.filterHasAttachmentsOnly){
params.filter_has_attachments = true;
}
return params;
},
updatePeerFromAnnounce: function(announce) {
this.peers[announce.destination_hash] = announce;
},
onPeerClick: function(peer) {
// update selected peer
this.selectedPeer = peer;
// update current route
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
const routeOptions = {
name: routeName,
params: {
destinationHash: peer.destination_hash,
},
};
if(!this.isPopoutMode && this.$route?.query){
routeOptions.query = { ...this.$route.query };
}
this.$router.replace(routeOptions);
},
onConversationClick: function(conversation) {
// object must stay compatible with format of peers
this.onPeerClick(conversation);
// mark conversation as read
this.$refs["conversation-viewer"].markConversationAsRead(conversation);
},
onCloseConversationViewer: function() {
// clear selected peer
this.selectedPeer = null;
if(this.isPopoutMode){
window.close();
return;
}
// update current route
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
const routeOptions = { name: routeName };
if(!this.isPopoutMode && this.$route?.query){
routeOptions.query = { ...this.$route.query };
}
this.$router.replace(routeOptions);
},
requestConversationsRefresh() {
if(this.conversationRefreshTimeout){
clearTimeout(this.conversationRefreshTimeout);
}
this.conversationRefreshTimeout = setTimeout(() => {
this.getConversations();
}, 250);
},
onConversationSearchChanged(term) {
this.conversationSearchTerm = term;
this.requestConversationsRefresh();
},
onConversationFilterChanged(filterKey) {
if(filterKey === 'unread'){
this.filterUnreadOnly = !this.filterUnreadOnly;
} else if(filterKey === 'failed'){
this.filterFailedOnly = !this.filterFailedOnly;
} else if(filterKey === 'attachments'){
this.filterHasAttachmentsOnly = !this.filterHasAttachmentsOnly;
}
this.requestConversationsRefresh();
},
getHashPopoutValue() {
const hash = window.location.hash || "";
const match = hash.match(/popout=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
},
},
computed: {
popoutRouteType() {
if(this.$route?.meta?.popoutType){
return this.$route.meta.popoutType;
}
return this.$route?.query?.popout ?? this.getHashPopoutValue();
},
isPopoutMode() {
return this.popoutRouteType === "conversation";
},
},
watch: {
conversations() {
// update global state
GlobalState.unreadConversationsCount = this.conversations.filter((conversation) => {
return conversation.is_unread;
}).length;
},
},
}
</script>

View File

@@ -2,25 +2,40 @@
<div class="flex flex-col w-80 min-w-80">
<!-- tabs -->
<div class="bg-white dark:bg-zinc-950 border-b border-r border-gray-200 dark:border-zinc-700">
<div class="bg-transparent border-b border-r border-gray-200/70 dark:border-zinc-700/80 backdrop-blur">
<div class="-mb-px flex">
<div @click="tab = 'conversations'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'conversations' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Conversations</div>
<div @click="tab = 'announces'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'announces' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-300']">Announces</div>
<div @click="tab = 'conversations'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-semibold tracking-wide uppercase cursor-pointer transition" :class="[ tab === 'conversations' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-200']">Conversations</div>
<div @click="tab = 'announces'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-semibold tracking-wide uppercase cursor-pointer transition" :class="[ tab === 'announces' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-200']">Announces</div>
</div>
</div>
<!-- conversations -->
<div v-if="tab === 'conversations'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden">
<div v-if="tab === 'conversations'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0">
<!-- search -->
<div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
<input v-model="conversationsSearchTerm" type="text" :placeholder="`Search ${conversations.length} Conversations...`" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
<!-- search + filters -->
<div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-2">
<input
:value="conversationSearchTerm"
@input="onConversationSearchInput"
type="text"
:placeholder="`Search ${conversations.length} conversations...`"
class="input-field">
<div class="flex flex-wrap gap-1">
<button type="button" @click="toggleFilter('unread')" :class="filterChipClasses(filterUnreadOnly)">Unread</button>
<button type="button" @click="toggleFilter('failed')" :class="filterChipClasses(filterFailedOnly)">Failed</button>
<button type="button" @click="toggleFilter('attachments')" :class="filterChipClasses(filterHasAttachmentsOnly)">Attachments</button>
</div>
</div>
<!-- peers -->
<!-- conversations -->
<div class="flex h-full overflow-y-auto">
<div v-if="searchedConversations.length > 0" class="w-full">
<div @click="onConversationClick(conversation)" v-for="conversation of searchedConversations" class="flex cursor-pointer p-2 border-l-2" :class="[ conversation.destination_hash === selectedDestinationHash ? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400' : 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600' ]">
<div v-if="displayedConversations.length > 0" class="w-full">
<div
v-for="conversation of displayedConversations"
:key="conversation.destination_hash"
@click="onConversationClick(conversation)"
class="flex cursor-pointer p-2 border-l-2"
:class="[ conversation.destination_hash === selectedDestinationHash ? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400' : 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600' ]">
<div class="my-auto mr-2">
<div v-if="conversation.lxmf_user_icon" class="p-2 rounded" :style="{ 'color': conversation.lxmf_user_icon.foreground_colour, 'background-color': conversation.lxmf_user_icon.background_colour }">
<MaterialDesignIcon :icon-name="conversation.lxmf_user_icon.icon_name" class="w-6 h-6"/>
@@ -29,22 +44,45 @@
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
</div>
</div>
<div class="mr-auto">
<div class="text-gray-900 dark:text-gray-100" :class="{ 'font-semibold': conversation.is_unread || conversation.failed_messages_count > 0 }">{{ conversation.custom_display_name ?? conversation.display_name }}</div>
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatTimeAgo(conversation.updated_at) }}</div>
<div class="mr-auto w-full pr-2 min-w-0">
<div class="flex justify-between gap-2 min-w-0">
<div class="text-gray-900 dark:text-gray-100 truncate min-w-0" :title="conversation.custom_display_name ?? conversation.display_name" :class="{ 'font-semibold': conversation.is_unread || conversation.failed_messages_count > 0 }">
{{ conversation.custom_display_name ?? conversation.display_name }}
</div>
<div class="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap flex-shrink-0">
{{ formatTimeAgo(conversation.updated_at) }}
</div>
</div>
<div class="text-gray-600 dark:text-gray-400 text-xs mt-0.5 truncate">
{{ conversation.latest_message_preview ?? conversation.latest_message_title ?? 'No messages yet' }}
</div>
</div>
<div v-if="conversation.is_unread" class="my-auto ml-2 mr-2">
<div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
</div>
<div v-else-if="conversation.failed_messages_count" class="my-auto ml-2 mr-2">
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
<div class="flex items-center space-x-1">
<div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4"/>
</div>
<div v-if="conversation.is_unread" class="my-auto ml-1">
<div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
</div>
<div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
</div>
</div>
</div>
</div>
<div v-else class="mx-auto my-auto text-center leading-5">
<div v-if="isLoading" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1 animate-spin text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</div>
<div class="font-semibold">Loading conversations</div>
</div>
<!-- no conversations at all -->
<div v-if="conversations.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
<div v-else-if="conversations.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z" />
@@ -55,25 +93,25 @@
</div>
<!-- is searching, but no results -->
<div v-if="conversationsSearchTerm !== '' && conversations.length > 0" class="flex flex-col text-gray-900 dark:text-gray-100">
<div v-else-if="conversationSearchTerm !== ''" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<div class="font-semibold">No Search Results</div>
<div>Your search didn't match any Conversations!</div>
<div>Your search didn't match any conversations.</div>
</div>
</div>
</div>
</div>
<!-- discover -->
<div v-if="tab === 'announces'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden">
<div v-if="tab === 'announces'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0">
<!-- search -->
<div v-if="peersCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
<input v-model="peersSearchTerm" type="text" :placeholder="`Search ${peersCount} recent announces...`" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
<input v-model="peersSearchTerm" type="text" :placeholder="`Search ${peersCount} recent announces...`" class="input-field">
</div>
<!-- peers -->
@@ -88,8 +126,8 @@
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
</div>
</div>
<div>
<div class="text-gray-900 dark:text-gray-100">{{ peer.custom_display_name ?? peer.display_name }}</div>
<div class="min-w-0 flex-1">
<div class="text-gray-900 dark:text-gray-100 truncate" :title="peer.custom_display_name ?? peer.display_name">{{ peer.custom_display_name ?? peer.display_name }}</div>
<div class="flex space-x-1 text-gray-500 dark:text-gray-400 text-sm">
<!-- time ago -->
@@ -150,15 +188,35 @@ import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: 'MessagesSidebar',
components: {MaterialDesignIcon},
emits: ["conversation-click", "peer-click", "conversation-search-changed", "conversation-filter-changed"],
props: {
peers: Object,
conversations: Array,
selectedDestinationHash: String,
conversationSearchTerm: {
type: String,
default: "",
},
filterUnreadOnly: {
type: Boolean,
default: false,
},
filterFailedOnly: {
type: Boolean,
default: false,
},
filterHasAttachmentsOnly: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
},
data() {
return {
tab: "conversations",
conversationsSearchTerm: "",
peersSearchTerm: "",
};
},
@@ -172,16 +230,23 @@ export default {
formatTimeAgo: function(datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
onConversationSearchInput(event) {
this.$emit("conversation-search-changed", event.target.value);
},
toggleFilter(filterKey) {
this.$emit("conversation-filter-changed", filterKey);
},
filterChipClasses(isActive) {
const base = "px-2 py-1 rounded-full text-xs font-semibold transition-colors";
if (isActive) {
return `${base} bg-blue-600 text-white dark:bg-blue-500`;
}
return `${base} bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-200`;
},
},
computed: {
searchedConversations() {
return this.conversations.filter((conversation) => {
const search = this.conversationsSearchTerm.toLowerCase();
const matchesDisplayName = conversation.display_name.toLowerCase().includes(search);
const matchesCustomDisplayName = conversation.custom_display_name?.toLowerCase()?.includes(search) === true;
const matchesDestinationHash = conversation.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
});
displayedConversations() {
return this.conversations;
},
peersCount() {
return Object.keys(this.peers).length;

View File

@@ -0,0 +1,79 @@
<template>
<div class="relative inline-flex rounded-xl shadow-sm">
<!-- send button -->
<button @click="send" :disabled="!canSendMessage" type="button" class="inline-flex items-center gap-2 rounded-l-xl px-4 py-2.5 text-sm font-semibold text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" :class="[ canSendMessage ? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500' : 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 cursor-not-allowed']">
<svg v-if="!isSendingMessage" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg>
<span v-if="isSendingMessage" class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Sending...
</span>
<span v-else>
<span v-if="deliveryMethod === 'direct'">Send (Direct)</span>
<span v-else-if="deliveryMethod === 'opportunistic'">Send (Opportunistic)</span>
<span v-else-if="deliveryMethod === 'propagated'">Send (Propagated)</span>
<span v-else>Send</span>
</span>
</button>
<div class="relative">
<!-- dropdown button -->
<button @click="showMenu" :disabled="!canSendMessage" type="button" class="border-l relative inline-flex items-center justify-center rounded-r-xl px-2.5 py-2.5 text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" :class="[ canSendMessage ? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500 border-blue-700 dark:border-blue-800' : 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 border-gray-500 dark:border-zinc-600 cursor-not-allowed']">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
</button>
<!-- dropdown menu -->
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95">
<div v-if="isShowingMenu" v-click-outside="hideMenu" class="absolute bottom-full right-0 mb-1 z-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none overflow-hidden min-w-[200px]">
<div class="py-1">
<button @click="setDeliveryMethod(null)" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap border-b border-gray-100 dark:border-zinc-800">Send Automatically</button>
<button @click="setDeliveryMethod('direct')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Send over Direct Link</button>
<button @click="setDeliveryMethod('opportunistic')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Send Opportunistically</button>
<button @click="setDeliveryMethod('propagated')" type="button" class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap">Send to Propagation Node</button>
</div>
</div>
</Transition>
</div>
</div>
</template>
<script>
export default {
name: 'SendMessageButton',
props: {
deliveryMethod: String,
canSendMessage: Boolean,
isSendingMessage: Boolean,
},
data() {
return {
isShowingMenu: false,
};
},
methods: {
showMenu() {
this.isShowingMenu = true;
},
hideMenu() {
this.isShowingMenu = false;
},
setDeliveryMethod(deliveryMethod) {
this.$emit("delivery-method-changed", deliveryMethod);
this.hideMenu();
},
send() {
this.$emit("send");
},
},
}
</script>

View File

@@ -3,38 +3,41 @@
<!-- network -->
<div id="network" class="w-full h-full"></div>
<!-- controls -->
<div class="absolute flex bottom-0 left-0 bg-gray-100 dark:bg-zinc-900 p-2">
<div class="bg-white dark:bg-zinc-800 rounded shadow min-w-52">
<div @click="isShowingControls = !isShowingControls" class="flex text-gray-700 dark:text-gray-300 p-2 cursor-pointer">
<div class="my-auto">Reticulum Network</div>
<div class="flex ml-auto">
<button
@click.stop="update"
type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-1 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-600"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 text-white">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
</div>
<div class="absolute bottom-4 left-4 z-10">
<div class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-2xl shadow-lg overflow-hidden min-w-[240px]">
<div @click="isShowingControls = !isShowingControls" class="flex items-center px-4 py-3 border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/50 backdrop-blur-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors">
<div class="flex-1 font-semibold text-gray-900 dark:text-zinc-100">Reticulum Network</div>
<button
@click.stop="update"
type="button"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white shadow-sm transition-colors"
:disabled="isUpdating"
>
<svg v-if="!isUpdating" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<svg v-else class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</div>
<div v-if="isShowingControls" class="divide-y dark:divide-zinc-700 text-gray-900 dark:text-white border-t border-gray-300 dark:border-zinc-700">
<div class="px-1 py-2">
<div class="flex items-start">
<div class="flex items-center h-5">
<input
v-model="autoReload"
type="checkbox"
class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-900 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-800"
>
</div>
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Auto Update (5 sec)</label>
</div>
<div v-if="isShowingControls" class="px-4 py-3 space-y-3">
<div class="flex items-center gap-2">
<input
v-model="autoReload"
type="checkbox"
id="auto-reload"
class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-white dark:bg-zinc-900 text-blue-600 focus:ring-2 focus:ring-blue-500/50 focus:ring-offset-0"
>
<label for="auto-reload" class="text-sm font-medium text-gray-900 dark:text-zinc-100 cursor-pointer">Auto Update (5 sec)</label>
</div>
<div class="p-1">
<div class="text-black dark:text-white">Interfaces</div>
<div class="text-sm text-gray-700 dark:text-gray-300">{{ onlineInterfaces.length }} Online, {{ offlineInterfaces.length }} Offline</div>
<div class="pt-2 border-t border-gray-200 dark:border-zinc-800">
<div class="text-sm font-semibold text-gray-900 dark:text-zinc-100 mb-1">Interfaces</div>
<div class="text-xs text-gray-600 dark:text-zinc-400">
<span class="text-green-600 dark:text-green-400 font-medium">{{ onlineInterfaces.length }}</span> Online,
<span class="text-red-600 dark:text-red-400 font-medium">{{ offlineInterfaces.length }}</span> Offline
</div>
</div>
</div>
</div>
@@ -45,7 +48,24 @@
<style>
.vis-tooltip {
color: white !important;
background: rgba(0, 0, 0, 0.75) !important;
background: rgba(0, 0, 0, 0.85) !important;
border-radius: 0.5rem !important;
padding: 0.5rem 0.75rem !important;
font-size: 0.875rem !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
}
.dark .vis-tooltip {
background: rgba(24, 24, 27, 0.95) !important;
border: 1px solid rgba(63, 63, 70, 0.5) !important;
}
#network {
background-color: rgb(249, 250, 251);
}
.dark #network {
background-color: rgb(9, 9, 11);
}
</style>
@@ -53,6 +73,7 @@
import "vis-network/styles/vis-network.css";
import { Network } from "vis-network";
import { DataSet } from "vis-data";
import * as mdi from "@mdi/js";
import Utils from "../../js/Utils";
export default {
name: 'NetworkVisualiser',
@@ -62,12 +83,15 @@ export default {
autoReload: false,
reloadInterval: null,
isShowingControls: true,
isUpdating: false,
interfaces: [],
pathTable: [],
announces: {},
conversations: {},
network: null,
nodes: new DataSet(),
edges: new DataSet(),
iconCache: {},
};
},
beforeUnmount() {
@@ -118,6 +142,70 @@ export default {
console.log(e);
}
},
async getConversations() {
try {
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
this.conversations = {};
for(const conversation of response.data.conversations){
this.conversations[conversation.destination_hash] = conversation;
}
} catch(e) {
console.log(e);
}
},
async createIconImage(iconName, foregroundColor, backgroundColor, size = 32) {
const cacheKey = `${iconName}-${foregroundColor}-${backgroundColor}-${size}`;
if(this.iconCache[cacheKey]){
return this.iconCache[cacheKey];
}
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// draw background circle
ctx.fillStyle = backgroundColor;
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2 - 1, 0, 2 * Math.PI);
ctx.fill();
// load MDI icon SVG
const iconSvg = this.getMdiIconSvg(iconName, foregroundColor);
const img = new Image();
const svgBlob = new Blob([iconSvg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
ctx.drawImage(img, size * 0.2, size * 0.2, size * 0.6, size * 0.6);
URL.revokeObjectURL(url);
const dataUrl = canvas.toDataURL();
this.iconCache[cacheKey] = dataUrl;
resolve(dataUrl);
};
img.onerror = () => {
URL.revokeObjectURL(url);
const dataUrl = canvas.toDataURL();
this.iconCache[cacheKey] = dataUrl;
resolve(dataUrl);
};
img.src = url;
});
},
getMdiIconSvg(iconName, foregroundColor) {
const mdiIconName = "mdi" + iconName.split("-").map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1);
}).join("");
const iconPath = mdi[mdiIconName] || mdi["mdiAccountOutline"];
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${foregroundColor}" d="${iconPath}"/></svg>`;
},
async createMdiIconImage(iconName, size = 32) {
const foregroundColor = '#ffffff';
const backgroundColor = '#6b7280';
return await this.createIconImage(iconName, foregroundColor, backgroundColor, size);
},
async init() {
// create network ui
@@ -135,11 +223,23 @@ export default {
},
nodes: {
color: {
border: "#000000",
border: "#e5e7eb",
highlight: {
border: "#000000",
border: "#3b82f6",
},
},
font: {
color: "#111827",
size: 14,
background: "rgba(255, 255, 255, 0.9)",
},
},
edges: {
color: {
color: "#9ca3af",
highlight: "#3b82f6",
},
width: 2,
},
physics: {
barnesHut: {
@@ -165,6 +265,60 @@ export default {
},
});
// handle double click on a node
this.network.on("doubleClick", (params) => {
// get clicked node id
const clickedNodeId = params.nodes[0];
if(!clickedNodeId){
return;
}
// find node by id
const node = this.network.body.nodes[clickedNodeId];
if(!node){
return;
}
// handle double click on an announce node
if(node.options.group === "announce"){
// get announce
const announce = node.options._announce;
if(!announce) {
return;
}
// handle double click on lxmf.delivery node
if(announce.aspect === "lxmf.delivery"){
// go to messages page for this destination hash
this.$router.push({
name: "messages",
params: {
destinationHash: announce.destination_hash,
},
});
}
// handle double click on nomadnetwork.node node
if(announce.aspect === "nomadnetwork.node"){
// go to nomadnetwork page for this destination hash
this.$router.push({
name: "nomadnetwork",
params: {
destinationHash: announce.destination_hash,
},
});
}
}
});
// update network
await this.update();
@@ -202,14 +356,24 @@ export default {
},
async update() {
await this.getConfig();
await this.getInterfaceStats();
await this.getPathTable();
await this.getAnnounces();
this.isUpdating = true;
try {
await this.getConfig();
await this.getInterfaceStats();
await this.getPathTable();
await this.getAnnounces();
await this.getConversations();
} finally {
this.isUpdating = false;
}
const nodes = [];
const edges = [];
const isDarkMode = document.documentElement.classList.contains('dark');
const fontColor = isDarkMode ? "#f4f4f5" : "#111827";
const fontBackground = isDarkMode ? "rgba(24, 24, 27, 0.9)" : "rgba(255, 255, 255, 0.9)";
// add me
nodes.push({
id: "me",
@@ -221,8 +385,8 @@ export default {
`Identity: ${this.config?.identity_hash ?? 'Unknown'}`,
].join("\n"),
font: {
color: "#000000",
background: "#ffffff",
color: fontColor,
background: fontBackground,
},
});
@@ -251,8 +415,8 @@ export default {
].join("\n"),
size: 30,
font: {
color: "#000000",
background: '#ffffff',
color: fontColor,
background: fontBackground,
},
shape: "circularImage",
image: entry.status ? "/assets/images/network-visualiser/interface_connected.png" : "/assets/images/network-visualiser/interface_disconnected.png",
@@ -268,12 +432,9 @@ export default {
id: `${entry.parent_interface_name}~${entry.name}`,
from: entry.parent_interface_name,
to: entry.name,
color: "transparent",
color: entry.status ? "#22c55e" : "#ef4444",
width: 3,
length: 300,
background: {
enabled: true,
color: entry.status ? "#22c55e" : "#ef4444",
},
});
} else {
// add edge from me to interface
@@ -281,12 +442,9 @@ export default {
id: `me~${entry.name}`,
from: "me",
to: entry.name,
color: "transparent",
color: entry.status ? "#22c55e" : "#ef4444",
width: 3,
length: 300,
background: {
enabled: true,
color: entry.status ? "#22c55e" : "#ef4444",
},
});
}
@@ -321,9 +479,22 @@ export default {
if(announce.aspect === "lxmf.delivery"){
const name = announce.custom_display_name ?? announce.display_name;
const conversation = this.conversations[announce.destination_hash];
node.shape = "circularImage";
node.image = entry.hops === 1 ? "/assets/images/network-visualiser/user_1hop.png" : "/assets/images/network-visualiser/user.png";
if(conversation?.lxmf_user_icon){
const iconImage = await this.createIconImage(
conversation.lxmf_user_icon.icon_name,
conversation.lxmf_user_icon.foreground_colour,
conversation.lxmf_user_icon.background_colour,
40
);
node.image = iconImage;
node.size = 30;
} else {
node.image = entry.hops === 1 ? "/assets/images/network-visualiser/user_1hop.png" : "/assets/images/network-visualiser/user.png";
}
node.label = name;
node.title = [
@@ -358,6 +529,9 @@ export default {
}
// attach announce to this node
node._announce = announce;
// add node
nodes.push(node);
@@ -366,7 +540,8 @@ export default {
id: `${entry.interface}~${entry.hash}`,
from: entry.interface,
to: entry.hash,
color: "gray",
color: isDarkMode ? "#71717a" : "#9ca3af",
width: 2,
});
}
@@ -447,3 +622,4 @@ export default {
},
}
</script>

View File

@@ -2,24 +2,78 @@
<!-- nomadnetwork sidebar -->
<NomadNetworkSidebar
v-if="!isPopoutMode"
:nodes="nodes"
:favourites="favourites"
:selected-destination-hash="selectedNode?.destination_hash"
@node-click="onNodeClick"/>
@node-click="onNodeClick"
@rename-favourite="onRenameFavourite"
@remove-favourite="onRemoveFavourite"/>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
<!-- node -->
<div v-if="selectedNode" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
<div v-if="selectedNode" class="flex flex-col h-full min-h-0 bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
<!-- header -->
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
<!-- favourite button -->
<div class="my-auto mr-2">
<div v-if="isFavourite(selectedNode.destination_hash)" @click="removeFavourite(selectedNode)" class="cursor-pointer">
<div class="flex text-yellow-500 dark:text-yellow-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
</svg>
</div>
</div>
</div>
<div v-else @click="addFavourite(selectedNode)" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</div>
</div>
</div>
</div>
<!-- node info -->
<div class="my-auto dark:text-gray-100">
<span class="font-semibold">{{ selectedNode.display_name }}</span>
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
<div class="my-auto dark:text-gray-100 flex-1 min-w-0 flex items-baseline gap-1">
<span class="font-semibold truncate inline-block max-w-xs sm:max-w-sm" :title="selectedNode.display_name">{{ selectedNode.display_name }}</span>
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer whitespace-nowrap"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
</div>
<!-- identify button -->
<div class="my-auto ml-auto mr-2">
<div @click="identify(selectedNode.destination_hash)" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.864 4.243A7.5 7.5 0 0 1 19.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 0 0 4.5 10.5a7.464 7.464 0 0 1-1.15 3.993m1.989 3.559A11.209 11.209 0 0 0 8.25 10.5a3.75 3.75 0 1 1 7.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 0 1-3.6 9.75m6.633-4.596a18.666 18.666 0 0 1-2.485 5.33" />
</svg>
</div>
</div>
</div>
</div>
<!-- popout button -->
<div class="my-auto mr-2">
<div @click="openNomadnetPopout" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
<path d="M17 3.75h3.25A.75.75 0 0 1 21 4.5v3.25a.75.75 0 0 1-1.5 0V6.31l-4.97 4.97a.75.75 0 1 1-1.06-1.06l4.97-4.97H17a.75.75 0 0 1 0-1.5Z"/>
<path d="M5.25 6A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75v-6a.75.75 0 0 0-1.5 0v6c0 .414-.336.75-.75.75H5.25a.75.75 0 0 1-.75-.75V8.25c0-.414.336-.75.75-.75h6a.75.75 0 0 0 0-1.5h-6Z"/>
</svg>
</div>
</div>
</div>
</div>
<!-- close button -->
<div class="my-auto ml-auto mr-2">
<div @click="selectedNode = null" class="cursor-pointer">
<div class="my-auto mr-2">
<div @click="onCloseNodeViewer" class="cursor-pointer">
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
@@ -33,7 +87,7 @@
<!-- browser navigation -->
<div class="flex w-full border-gray-300 dark:border-zinc-800 border-b p-2">
<button @click="loadNodePage(selectedNode.destination_hash, '/page/index.mu')" type="button" class="my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer">
<button @click="loadNodePage(selectedNode.destination_hash, defaultNodePagePath)" type="button" class="my-auto text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clip-rule="evenodd" />
</svg>
@@ -43,6 +97,11 @@
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z" clip-rule="evenodd" />
</svg>
</button>
<button @click="toggleNodePageSource" type="button" title="Toggle Source Code" class="ml-1 my-auto text-gray-500 dark:text-gray-300 rounded p-1 cursor-pointer" :class="[ isShowingNodePageSource ? 'bg-green-500 hover:bg-green-600 text-white' : 'bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' ]">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
</svg>
</button>
<button @click="loadPreviousNodePage" type="button" :disabled="nodePagePathHistory.length === 0" :class="[ nodePagePathHistory.length > 0 ? 'text-gray-500 dark:text-gray-300 bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700' : 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-zinc-900']" class="ml-1 my-auto rounded p-1 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
@@ -59,7 +118,7 @@
</div>
<!-- page content -->
<div class="h-full overflow-y-scroll p-3 bg-black text-white nodeContainer">
<div class="flex-1 overflow-y-auto p-3 bg-black text-white nodeContainer">
<div class="flex" v-if="isLoadingNodePage">
<div class="my-auto">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -67,9 +126,12 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div class="my-auto">Loading {{ nodePageProgress }}%</div>
<div class="my-auto flex-1">Loading {{ nodePageProgress }}%</div>
<button @click="cancelPageDownload" type="button" class="my-auto text-white bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 rounded px-3 py-1 text-sm font-semibold cursor-pointer ml-3">
Cancel
</button>
</div>
<pre v-else v-html="nodePageContent" class="h-full text-wrap"></pre>
<pre v-else v-html="renderedNodePageContent()" class="h-full break-words whitespace-pre-wrap"></pre>
</div>
<!-- file download bottom bar -->
@@ -80,7 +142,15 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<div class="my-auto">Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)</div>
<div class="my-auto flex-1">
Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)
<span v-if="nodeFileDownloadSpeed !== null" class="ml-2 text-sm">
- {{ formatBytesPerSecond(nodeFileDownloadSpeed) }}
</span>
</div>
<button @click="cancelFileDownload" type="button" class="my-auto text-white bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 rounded px-3 py-1 text-sm font-semibold cursor-pointer">
Cancel
</button>
</div>
</div>
@@ -93,6 +163,13 @@
</div>
<div class="font-semibold">No Active Node</div>
<div>Select a Node to start browsing!</div>
<div class="mx-auto mt-2">
<button @click.stop="openUrl" type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500
dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
Open a Nomadnet URL
</button>
</div>
</div>
</div>
@@ -123,25 +200,35 @@ pre a:hover {
<script>
import MicronParser from "../../js/MicronParser";
import MicronParser from "micron-parser";
import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
import GlobalEmitter from "../../js/GlobalEmitter";
import Utils from "../../js/Utils";
export default {
name: 'NomadNetworkPage',
components: {
NomadNetworkSidebar,
},
props: {
destinationHash: String,
},
data() {
return {
reloadInterval: null,
nodes: {},
selectedNode: null,
selectedNodePath: null,
favourites: [],
isLoadingNodePage: false,
isShowingNodePageSource: false,
defaultNodePagePath: "/page/index.mu",
nodePageRequestSequence: 0,
nodePagePath: null,
nodePagePathUrlInput: null,
@@ -149,11 +236,16 @@ export default {
nodePageProgress: 0,
nodePagePathHistory: [],
nodePageCache: {},
currentPageDownloadId: null,
isDownloadingNodeFile: false,
nodeFilePath: null,
nodeFileProgress: 0,
nodeFileDownloadStartTime: null,
nodeFileLastProgressTime: null,
nodeFileLastProgressValue: 0,
nodeFileDownloadSpeed: null,
currentFileDownloadId: null,
nomadnetPageDownloadCallbacks: {},
nomadnetFileDownloadCallbacks: {},
@@ -161,23 +253,79 @@ export default {
};
},
beforeUnmount() {
clearInterval(this.reloadInterval);
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
// stop listening for element clicks
window.document.removeEventListener('click', this.onElementClick);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
// fixme: this is called by the micron-parser.js
window.onNodePageUrlClick = (url, options = null) => {
this.onNodePageUrlClick(url, options);
};
// listen for element clicks
window.document.addEventListener('click', this.onElementClick);
// load nomadnetwork node if a destination hash was provided on page load
if(this.destinationHash){
(async () => {
// fetch updated announce as we are probably loading node page before we loaded the announces list
await this.getNomadnetworkNodeAnnounce(this.destinationHash);
await this.onNodePageUrlClick(`${this.destinationHash}:${this.defaultNodePagePath}`);
})();
}
this.getFavourites();
this.getNomadnetworkNodeAnnounces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.getFavourites();
}, 5000);
},
computed: {
popoutRouteType() {
if(this.$route?.meta?.popoutType){
return this.$route.meta.popoutType;
}
return this.$route?.query?.popout ?? this.getHashPopoutValue();
},
isPopoutMode() {
return this.popoutRouteType === "nomad";
},
},
methods: {
openNomadnetPopout() {
if (!this.selectedNode) {
return;
}
const destinationHash = this.selectedNode.destination_hash || "";
const encodedHash = encodeURIComponent(destinationHash);
const url = `${window.location.origin}${window.location.pathname}#/popout/nomadnetwork/${encodedHash}`;
window.open(url, "_blank", "width=1100,height=800,noopener");
},
onElementClick(event) {
// find the closest ancestor (or the clicked element itself) with data-action="openNode"
const element = event.target.closest('[data-action="openNode"]');
if(!element){
return;
}
// get the destination and fields
const destination = element.getAttribute("data-destination");
const fields = element.getAttribute("data-fields");
// navigate to destination
this.onNodePageUrlClick(destination, fields);
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
switch(json.type){
@@ -192,6 +340,13 @@ export default {
// get data from server
const nomadnetPageDownload = json.nomadnet_page_download;
const downloadId = json.download_id;
// handle started status
if(nomadnetPageDownload.status === "started"){
this.currentPageDownloadId = downloadId;
return;
}
// find download callbacks
const getNomadnetPageDownloadCallbackKey = this.getNomadnetPageDownloadCallbackKey(nomadnetPageDownload.destination_hash, nomadnetPageDownload.page_path);
@@ -205,6 +360,7 @@ export default {
if(nomadnetPageDownload.status === "success" && nomadnetPageDownloadCallback.onSuccessCallback){
nomadnetPageDownloadCallback.onSuccessCallback(nomadnetPageDownload.page_content);
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
this.currentPageDownloadId = null;
return;
}
@@ -212,6 +368,7 @@ export default {
if(nomadnetPageDownload.status === "failure" && nomadnetPageDownloadCallback.onFailureCallback){
nomadnetPageDownloadCallback.onFailureCallback(nomadnetPageDownload.failure_reason);
delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
this.currentPageDownloadId = null;
return;
}
@@ -228,6 +385,13 @@ export default {
// get data from server
const nomadnetFileDownload = json.nomadnet_file_download;
const downloadId = json.download_id;
// handle started status
if(nomadnetFileDownload.status === "started"){
this.currentFileDownloadId = downloadId;
return;
}
// find download callbacks
const getNomadnetFileDownloadCallbackKey = this.getNomadnetFileDownloadCallbackKey(nomadnetFileDownload.destination_hash, nomadnetFileDownload.file_path);
@@ -241,6 +405,7 @@ export default {
if(nomadnetFileDownload.status === "success" && nomadnetFileDownloadCallback.onSuccessCallback){
nomadnetFileDownloadCallback.onSuccessCallback(nomadnetFileDownload.file_name, nomadnetFileDownload.file_bytes);
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
this.currentFileDownloadId = null;
return;
}
@@ -248,6 +413,7 @@ export default {
if(nomadnetFileDownload.status === "failure" && nomadnetFileDownloadCallback.onFailureCallback){
nomadnetFileDownloadCallback.onFailureCallback(nomadnetFileDownload.failure_reason);
delete this.nomadnetFileDownloadCallbacks[getNomadnetFileDownloadCallbackKey];
this.currentFileDownloadId = null;
return;
}
@@ -260,11 +426,79 @@ export default {
break;
}
case 'nomadnet.download.cancelled': {
// handle download cancellation
const downloadId = json.download_id;
// clear page download if it matches
if(this.currentPageDownloadId === downloadId){
this.currentPageDownloadId = null;
this.isLoadingNodePage = false;
this.nodePageContent = "Download cancelled";
}
// clear file download if it matches
if(this.currentFileDownloadId === downloadId){
this.currentFileDownloadId = null;
this.isDownloadingNodeFile = false;
this.nodeFileDownloadSpeed = null;
}
break;
}
}
},
onDestinationPathClick: function(path) {
DialogUtils.alert(`${path.hops} ${ path.hops === 1 ? 'hop' : 'hops' } away via ${path.next_hop_interface}`);
},
async getFavourites() {
try {
const response = await window.axios.get("/api/v1/favourites", {
params: {
aspect: "nomadnetwork.node",
},
});
this.favourites = response.data.favourites;
} catch(e) {
// do nothing if failed to load favourites
console.log(e);
}
},
isFavourite(destinationHash) {
return this.favourites.find((favourite) => {
return favourite.destination_hash === destinationHash;
}) != null;
},
async addFavourite(node) {
// add to favourites
try {
await window.axios.post("/api/v1/favourites/add", {
destination_hash: node.destination_hash,
display_name: node.display_name,
aspect: "nomadnetwork.node",
});
} catch(e) {
console.log(e);
}
// update favourites
this.getFavourites();
},
async removeFavourite(node) {
// remove from favourites
try {
await window.axios.delete(`/api/v1/favourites/${node.destination_hash}`);
} catch(e) {
console.log(e);
}
// update favourites
this.getFavourites();
},
async getNomadnetworkNodeAnnounces() {
try {
@@ -272,6 +506,7 @@ export default {
const response = await window.axios.get(`/api/v1/announces`, {
params: {
aspect: "nomadnetwork.node",
limit: 500, // limit ui to showing 500 latest announces
},
});
@@ -286,11 +521,58 @@ export default {
console.log(e);
}
},
async getNomadnetworkNodeAnnounce(destinationHash) {
try {
// fetch announces for "nomadnetwork.node" aspect
const response = await window.axios.get(`/api/v1/announces`, {
params: {
destination_hash: destinationHash,
limit: 1,
},
});
// update ui
const nodeAnnounces = response.data.announces;
for(const nodeAnnounce of nodeAnnounces){
this.updateNodeFromAnnounce(nodeAnnounce);
}
} catch(e) {
// do nothing if failed to load announce
console.log(e);
}
},
updateNodeFromAnnounce: function(announce) {
this.nodes[announce.destination_hash] = announce;
},
async openUrl() {
// ask for url
const url = await DialogUtils.prompt("Enter a Nomadnet URL");
if(!url){
return;
}
// navigate to the url
await this.onNodePageUrlClick(url);
},
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
// update current route
const routeName = this.isPopoutMode ? "nomadnetwork-popout" : "nomadnetwork";
const routeOptions = {
name: routeName,
params: {
destinationHash: destinationHash,
},
};
if(!this.isPopoutMode && this.$route?.query){
routeOptions.query = { ...this.$route.query };
}
this.$router.replace(routeOptions);
// get new sequence for this page load
const seq = ++this.nodePageRequestSequence;
@@ -324,6 +606,7 @@ export default {
// if page is cache, we can just return it now
if(cachedNodePageContent != null){
this.nodePageContent = cachedNodePageContent;
this.renderPageContent(pagePath, cachedNodePageContent);
this.isLoadingNodePage = false;
return;
}
@@ -332,31 +615,21 @@ export default {
this.downloadNomadNetPage(destinationHash, pagePath, fieldData, (pageContent) => {
const muParser = new MicronParser();
// do nothing if callback is for a previous request
if(seq !== this.nodePageRequestSequence){
console.log("ignoring page content callback for previous page request")
return;
}
// check if page url ends with .mu but remove page data first
// address:/page/index.mu`Data=123
const [ pagePathWithoutData, pageData ] = pagePath.split("`");
// convert micron to html if page ends with .mu extension
// otherwise, we will just serve the content as is
if(pagePathWithoutData.endsWith(".mu")){
this.nodePageContent = muParser.convertMicronToHtml(pageContent);
} else {
this.nodePageContent = pageContent;
}
// update page content
this.nodePageContent = pageContent;
// update cache
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
// update page content
this.renderPageContent(pagePath, pageContent);
this.isLoadingNodePage = false;
// update node path
@@ -390,6 +663,35 @@ export default {
});
},
renderPageContent(path, content) {
// render page content if we aren't viewing source
if(!this.isShowingNodePageSource){
// check if page url ends with .mu but remove page data first
// address:/page/index.mu`Data=123
const [ pagePathWithoutData ] = path.split("`");
// convert micron to html if page ends with .mu extension
if(pagePathWithoutData.endsWith(".mu")){
const muParser = new MicronParser();
return muParser.convertMicronToHtml(content);
}
}
// otherwise, we will just serve the raw content, making sure to prevent injecting html
return content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
},
toggleNodePageSource() {
this.isShowingNodePageSource = !this.isShowingNodePageSource;
},
async reloadNodePage() {
// reload current node page without adding to history and without using cache
@@ -416,9 +718,9 @@ export default {
// remove leading ":"
var path = url.substring(1);
// if page path is empty we should load "/page/index.mu"
// if page path is empty we should load default page path
if(path === ""){
path = "/page/index.mu";
path = this.defaultNodePagePath;
}
return {
@@ -448,7 +750,7 @@ export default {
if(url.length === 32){
return {
destination_hash: url,
path: "/page/index.mu",
path: this.defaultNodePagePath,
};
}
@@ -520,8 +822,13 @@ export default {
if(url.startsWith("lxmf@")){
const destinationHash = url.replace("lxmf@", "");
if(destinationHash.length === 32){
await this.$router.push({ name: "messages" });
GlobalEmitter.emit("compose-new-message", destinationHash);
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
await this.$router.push({
name: routeName,
params: {
destinationHash: destinationHash,
},
});
return;
}
}
@@ -546,26 +853,72 @@ export default {
this.isDownloadingNodeFile = true;
this.nodeFilePath = parsedUrl.path.split("/").pop();
this.nodeFileProgress = 0;
this.nodeFileDownloadStartTime = Date.now();
this.nodeFileLastProgressTime = Date.now();
this.nodeFileLastProgressValue = 0;
this.nodeFileDownloadSpeed = null;
// start file download
this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => {
// Calculate final download speed based on actual file size
if (this.nodeFileDownloadStartTime) {
const totalTime = (Date.now() - this.nodeFileDownloadStartTime) / 1000; // seconds
const fileSizeBytes = atob(fileBytesBase64).length;
if (totalTime > 0) {
this.nodeFileDownloadSpeed = fileSizeBytes / totalTime;
}
}
// no longer downloading
this.isDownloadingNodeFile = false;
// download file to browser
this.downloadFileFromBase64(fileName, fileBytesBase64);
// Clear speed after a moment
setTimeout(() => {
this.nodeFileDownloadSpeed = null;
}, 2000);
}, (failureReason) => {
// no longer downloading
this.isDownloadingNodeFile = false;
this.nodeFileDownloadSpeed = null;
// show error message
DialogUtils.alert(`Failed to download file: ${failureReason}`);
}, (progress) => {
this.nodeFileProgress = Math.round(progress * 100);
const currentTime = Date.now();
const progressValue = progress;
this.nodeFileProgress = Math.round(progressValue * 100);
// Calculate estimated download speed based on progress rate
if (this.nodeFileDownloadStartTime && progressValue > 0) {
const elapsedTime = (currentTime - this.nodeFileDownloadStartTime) / 1000; // seconds
if (elapsedTime > 0.5) { // Only calculate after at least 0.5 seconds
// Estimate total file size based on progress rate
// If we've downloaded progressValue in elapsedTime, estimate total time
const estimatedTotalTime = elapsedTime / progressValue;
// Estimate file size based on average download speed assumption
// We'll refine this when download completes with actual size
// For now, estimate based on typical mesh network file sizes (100KB-10MB range)
// Use a conservative estimate that will be updated when download completes
const estimatedFileSize = 500 * 1024; // Start with 500KB estimate
const estimatedBytesDownloaded = estimatedFileSize * progressValue;
const estimatedSpeed = estimatedBytesDownloaded / elapsedTime;
// Only update if we have a reasonable estimate
if (estimatedSpeed > 0 && estimatedSpeed < 100 * 1024 * 1024) { // Cap at 100MB/s
this.nodeFileDownloadSpeed = estimatedSpeed;
}
}
}
this.nodeFileLastProgressTime = currentTime;
this.nodeFileLastProgressValue = progressValue;
});
return;
@@ -619,9 +972,70 @@ export default {
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
},
formatBytesPerSecond: function(bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
onNodeClick: function(node) {
// update selected node
this.selectedNode = node;
this.loadNodePage(node.destination_hash, "/page/index.mu");
// load default node page
this.loadNodePage(node.destination_hash, this.defaultNodePagePath);
},
async onRenameFavourite(favourite) {
// ask user for new display name
const displayName = await DialogUtils.prompt("Rename this favourite");
if(displayName == null){
return;
}
try {
// rename on server
await axios.post(`/api/v1/favourites/${favourite.destination_hash}/rename`, {
display_name: displayName,
});
// reload favourites
await this.getFavourites();
} catch(e) {
console.log(e);
DialogUtils.alert("Failed to rename favourite");
}
},
async onRemoveFavourite(favourite) {
// ask user to confirm
if(!await DialogUtils.confirm("Are you sure you want to remove this favourite?")){
return;
}
this.removeFavourite(favourite);
},
onCloseNodeViewer: function() {
// clear selected node
this.selectedNode = null;
if(this.isPopoutMode){
window.close();
return;
}
// update current route
const routeName = this.isPopoutMode ? "nomadnetwork-popout" : "nomadnetwork";
const routeOptions = { name: routeName };
if(!this.isPopoutMode && this.$route?.query){
routeOptions.query = { ...this.$route.query };
}
this.$router.replace(routeOptions);
},
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
return `${destinationHash}:${pagePath}`;
@@ -647,6 +1061,29 @@ export default {
}
},
async identify(destinationHash) {
try {
// ask user to confirm
if(!await DialogUtils.confirm("Are you sure you want to identify yourself to this NomadNetwork Node? The page will reload after your identity has been sent.")){
return;
}
// identify self to nomadnetwork node
await window.axios.post(`/api/v1/nomadnetwork/${destinationHash}/identify`);
// reload page
this.reloadNodePage();
} catch(e) {
DialogUtils.alert(e.response?.data?.message ?? "Failed to identify!");
}
},
getHashPopoutValue() {
const hash = window.location.hash || "";
const match = hash.match(/popout=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
},
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
try {
@@ -694,6 +1131,25 @@ export default {
console.error(e);
}
},
renderedNodePageContent() {
return this.renderPageContent(this.nodePagePath, this.nodePageContent);
},
cancelPageDownload() {
if(this.currentPageDownloadId !== null){
WebSocketConnection.send(JSON.stringify({
"type": "nomadnet.download.cancel",
"download_id": this.currentPageDownloadId,
}));
}
},
cancelFileDownload() {
if(this.currentFileDownloadId !== null){
WebSocketConnection.send(JSON.stringify({
"type": "nomadnet.download.cancel",
"download_id": this.currentFileDownloadId,
}));
}
},
},
}
</script>

View File

@@ -0,0 +1,281 @@
<template>
<div class="flex flex-col w-80 min-w-80 min-h-0 bg-white/90 dark:bg-zinc-950/80 backdrop-blur border-r border-gray-200 dark:border-zinc-800">
<div class="flex">
<button @click="tab = 'favourites'" type="button" class="sidebar-tab" :class="{ 'sidebar-tab--active': tab === 'favourites' }">
Favourites
</button>
<button @click="tab = 'announces'" type="button" class="sidebar-tab" :class="{ 'sidebar-tab--active': tab === 'announces' }">
Announces
</button>
</div>
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col min-h-0">
<div class="p-3 border-b border-gray-200 dark:border-zinc-800">
<input v-model="favouritesSearchTerm" type="text" :placeholder="`Search ${favourites.length} favourites...`" class="input-field"/>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div v-if="searchedFavourites.length > 0" class="space-y-2 pt-2">
<div
v-for="favourite of searchedFavourites"
:key="favourite.destination_hash"
@click="onFavouriteClick(favourite)"
class="favourite-card"
:class="[
favourite.destination_hash === selectedDestinationHash ? 'favourite-card--active' : '',
draggingFavouriteHash === favourite.destination_hash ? 'favourite-card--dragging' : ''
]"
draggable="true"
@dragstart="onFavouriteDragStart($event, favourite)"
@dragover.prevent="onFavouriteDragOver($event)"
@drop.prevent="onFavouriteDrop($event, favourite)"
@dragend="onFavouriteDragEnd"
>
<div class="favourite-card__icon">
<MaterialDesignIcon icon-name="server-network" class="w-5 h-5"/>
</div>
<div class="flex-1">
<div class="text-sm font-semibold text-gray-900 dark:text-white truncate" :title="favourite.display_name">{{ favourite.display_name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ formatDestinationHash(favourite.destination_hash) }}</div>
</div>
<DropDownMenu>
<template #button>
<IconButton class="bg-transparent dark:bg-transparent w-8 h-8 p-0 flex items-center justify-center">
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5"/>
</IconButton>
</template>
<template #items>
<DropDownMenuItem @click="onRenameFavourite(favourite)">
<MaterialDesignIcon icon-name="pencil" class="w-5 h-5"/>
<span>Rename</span>
</DropDownMenuItem>
<DropDownMenuItem @click="onRemoveFavourite(favourite)">
<MaterialDesignIcon icon-name="trash-can" class="w-5 h-5 text-red-500"/>
<span class="text-red-500">Remove</span>
</DropDownMenuItem>
</template>
</DropDownMenu>
</div>
</div>
<div v-else class="empty-state">
<MaterialDesignIcon icon-name="star-outline" class="w-8 h-8"/>
<div class="font-semibold">No favourites</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Add nodes from the announces tab.</div>
</div>
</div>
</div>
<div v-else class="flex-1 flex flex-col min-h-0">
<div class="p-3 border-b border-gray-200 dark:border-zinc-800">
<input v-model="nodesSearchTerm" type="text" placeholder="Search announces" class="input-field"/>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div v-if="searchedNodes.length > 0" class="space-y-2 pt-2">
<div v-for="node of searchedNodes" :key="node.destination_hash" @click="onNodeClick(node)" class="announce-card" :class="{ 'announce-card--active': node.destination_hash === selectedDestinationHash }">
<div class="announce-card__icon">
<MaterialDesignIcon icon-name="satellite-uplink" class="w-5 h-5"/>
</div>
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-white truncate" :title="node.display_name">{{ node.display_name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Announced {{ formatTimeAgo(node.updated_at) }}</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<MaterialDesignIcon icon-name="radar" class="w-8 h-8"/>
<div class="font-semibold">No announces yet</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Listening for peers on the mesh.</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Utils from "../../js/Utils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DropDownMenu from "../DropDownMenu.vue";
import IconButton from "../IconButton.vue";
import DropDownMenuItem from "../DropDownMenuItem.vue";
export default {
name: 'NomadNetworkSidebar',
components: {DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon},
props: {
nodes: Object,
favourites: Array,
selectedDestinationHash: String,
},
data() {
return {
tab: "favourites",
favouritesSearchTerm: "",
nodesSearchTerm: "",
favouritesOrder: [],
draggingFavouriteHash: null,
};
},
mounted() {
this.loadFavouriteOrder();
this.ensureFavouriteOrder();
},
watch: {
favourites: {
handler() {
this.ensureFavouriteOrder();
},
deep: true,
},
},
methods: {
onNodeClick(node) {
this.$emit("node-click", node);
},
onFavouriteClick(favourite) {
this.onNodeClick(favourite);
},
onRenameFavourite(favourite) {
this.$emit("rename-favourite", favourite);
},
onRemoveFavourite(favourite) {
this.$emit("remove-favourite", favourite);
},
loadFavouriteOrder() {
try {
const stored = localStorage.getItem("meshchat.nomadnet.favourites");
if(stored){
this.favouritesOrder = JSON.parse(stored);
}
} catch(e) {
console.log(e);
}
},
persistFavouriteOrder() {
localStorage.setItem("meshchat.nomadnet.favourites", JSON.stringify(this.favouritesOrder));
},
ensureFavouriteOrder() {
const hashes = this.favourites.map((fav) => fav.destination_hash);
const nextOrder = this.favouritesOrder.filter((hash) => hashes.includes(hash));
hashes.forEach((hash) => {
if(!nextOrder.includes(hash)){
nextOrder.push(hash);
}
});
if(JSON.stringify(nextOrder) !== JSON.stringify(this.favouritesOrder)){
this.favouritesOrder = nextOrder;
this.persistFavouriteOrder();
}
},
onFavouriteDragStart(event, favourite) {
try {
if(event?.dataTransfer){
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", favourite.destination_hash);
}
} catch(e) {
// ignore for browsers that prevent setting drag meta
}
this.draggingFavouriteHash = favourite.destination_hash;
},
onFavouriteDragOver(event) {
if(event?.dataTransfer){
event.dataTransfer.dropEffect = "move";
}
},
onFavouriteDrop(event, targetFavourite) {
if(!this.draggingFavouriteHash || this.draggingFavouriteHash === targetFavourite.destination_hash){
return;
}
const fromIndex = this.favouritesOrder.indexOf(this.draggingFavouriteHash);
const toIndex = this.favouritesOrder.indexOf(targetFavourite.destination_hash);
if(fromIndex === -1 || toIndex === -1){
return;
}
const updated = [...this.favouritesOrder];
updated.splice(fromIndex, 1);
updated.splice(toIndex, 0, this.draggingFavouriteHash);
this.favouritesOrder = updated;
this.persistFavouriteOrder();
this.draggingFavouriteHash = null;
},
onFavouriteDragEnd() {
this.draggingFavouriteHash = null;
},
formatTimeAgo: function(datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
formatDestinationHash: function(destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
},
computed: {
nodesCount() {
return Object.keys(this.nodes).length;
},
nodesOrderedByLatestAnnounce() {
const nodes = Object.values(this.nodes);
return nodes.sort(function(nodeA, nodeB) {
// order by updated_at desc
const nodeAUpdatedAt = new Date(nodeA.updated_at).getTime();
const nodeBUpdatedAt = new Date(nodeB.updated_at).getTime();
return nodeBUpdatedAt - nodeAUpdatedAt;
});
},
searchedNodes() {
return this.nodesOrderedByLatestAnnounce.filter((node) => {
const search = this.nodesSearchTerm.toLowerCase();
const matchesDisplayName = node.display_name.toLowerCase().includes(search);
const matchesDestinationHash = node.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesDestinationHash;
});
},
orderedFavourites() {
return [...this.favourites].sort((a, b) => {
return this.favouritesOrder.indexOf(a.destination_hash) - this.favouritesOrder.indexOf(b.destination_hash);
});
},
searchedFavourites() {
return this.orderedFavourites.filter((favourite) => {
const search = this.favouritesSearchTerm.toLowerCase();
const matchesDisplayName = favourite.display_name.toLowerCase().includes(search);
const matchesCustomDisplayName = favourite.custom_display_name?.toLowerCase()?.includes(search) === true;
const matchesDestinationHash = favourite.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
});
},
},
}
</script>
<style scoped>
.sidebar-tab {
@apply w-1/2 py-3 text-sm font-semibold text-gray-500 dark:text-gray-400 border-b-2 border-transparent transition;
}
.sidebar-tab--active {
@apply text-blue-600 border-blue-500 dark:text-blue-300 dark:border-blue-400;
}
.favourite-card {
@apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500;
}
.favourite-card--active {
@apply border-blue-500 dark:border-blue-400 bg-blue-50/60 dark:bg-blue-900/30;
}
.favourite-card__icon,
.announce-card__icon {
@apply w-10 h-10 rounded-xl bg-gray-100 dark:bg-zinc-800 flex items-center justify-center text-gray-500 dark:text-gray-300;
}
.favourite-card--dragging {
@apply opacity-60 ring-2 ring-blue-300 dark:ring-blue-600;
}
.announce-card {
@apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500;
}
.announce-card--active {
@apply border-blue-500 dark:border-blue-400 bg-blue-50/70 dark:bg-blue-900/30;
}
.empty-state {
@apply flex flex-col items-center justify-center text-center gap-2 text-gray-500 dark:text-gray-400 mt-20;
}
</style>

View File

@@ -1,59 +1,85 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
<div class="flex flex-col h-full space-y-2 p-2 overflow-y-auto">
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900">
<div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-4xl mx-auto">
<!-- appearance -->
<div class="bg-white dark:bg-zinc-800 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Ping</div>
<div class="dark:divide-zinc-700 text-gray-900 dark:text-gray-100 p-2">
Only lxmf.delivery destinations can be pinged.
</div>
</div>
<div class="glass-card space-y-5">
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Diagnostics</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">Ping Mesh Peers</div>
<div class="text-sm text-gray-600 dark:text-gray-300">Only <code class="font-mono text-xs">lxmf.delivery</code> destinations respond to ping.</div>
</div>
<!-- inputs -->
<div class="bg-white dark:bg-zinc-800 rounded shadow">
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
<div class="p-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Destination Hash</div>
<div class="flex">
<input v-model="destinationHash" type="text" placeholder="e.g: 7b746057a7294469799cd8d7d429676a" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="glass-label">Destination Hash</label>
<input v-model="destinationHash" type="text" placeholder="e.g. 7b746057a7294469799cd8d7d429676a" class="input-field font-mono"/>
</div>
<div>
<label class="glass-label">Ping Timeout (seconds)</label>
<input v-model="timeout" type="number" min="1" class="input-field"/>
</div>
</div>
<div class="p-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Ping Timeout (seconds)</div>
<div class="flex">
<input v-model="timeout" type="number" placeholder="Timeout" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
</div>
</div>
<div class="p-2 space-x-1">
<button v-if="!isRunning" @click="start" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
Start
<div class="flex flex-wrap gap-2">
<button v-if="!isRunning" @click="start" type="button" class="primary-chip px-4 py-2 text-sm">
<MaterialDesignIcon icon-name="play" class="w-4 h-4"/>
Start Ping
</button>
<button v-if="isRunning" @click="stop" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
<button v-else @click="stop" type="button" class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50">
<MaterialDesignIcon icon-name="pause" class="w-4 h-4"/>
Stop
</button>
<button @click="clear" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
<button @click="clear" type="button" class="secondary-chip px-4 py-2 text-sm">
<MaterialDesignIcon icon-name="broom" class="w-4 h-4"/>
Clear Results
</button>
<button @click="dropPath" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
<button @click="dropPath" type="button" class="inline-flex items-center gap-2 rounded-full bg-red-600/90 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-500 transition">
<MaterialDesignIcon icon-name="link-variant-remove" class="w-4 h-4"/>
Drop Path
</button>
</div>
<div class="flex flex-wrap gap-2 text-xs font-semibold">
<span :class="[isRunning ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200' : 'bg-gray-200 text-gray-700 dark:bg-zinc-800 dark:text-gray-200', 'rounded-full px-3 py-1']">
Status: {{ isRunning ? 'Running' : 'Idle' }}
</span>
<span v-if="lastPingSummary?.duration" class="rounded-full px-3 py-1 bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
Last RTT: {{ lastPingSummary.duration }}
</span>
<span v-if="lastPingSummary?.error" class="rounded-full px-3 py-1 bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200">
Last Error: {{ lastPingSummary.error }}
</span>
</div>
</div>
</div>
<!-- results -->
<div class="flex flex-col h-full bg-white dark:bg-zinc-800 rounded shadow overflow-hidden min-h-52">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Results</div>
<div id="results" class="flex flex-col h-full bg-black text-white dark:bg-zinc-800 dark:text-gray-200 p-2 overflow-y-auto overflow-x-auto font-mono whitespace-nowrap">
<div v-for="pingResult of pingResults" class="w-fit">{{ pingResult }}</div>
<div class="glass-card flex flex-col min-h-[320px] space-y-3">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-white">Console Output</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Streaming seq responses in real time</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
seq #{{ seq }}
</div>
</div>
<div v-if="lastPingSummary && !lastPingSummary.error" class="flex flex-wrap gap-2 text-xs text-gray-700 dark:text-gray-200">
<span v-if="lastPingSummary.hopsThere != null" class="stat-chip">Hops there: {{ lastPingSummary.hopsThere }}</span>
<span v-if="lastPingSummary.hopsBack != null" class="stat-chip">Hops back: {{ lastPingSummary.hopsBack }}</span>
<span v-if="lastPingSummary.rssi != null" class="stat-chip">RSSI {{ lastPingSummary.rssi }} dBm</span>
<span v-if="lastPingSummary.snr != null" class="stat-chip">SNR {{ lastPingSummary.snr }} dB</span>
<span v-if="lastPingSummary.quality != null" class="stat-chip">Quality {{ lastPingSummary.quality }}%</span>
<span v-if="lastPingSummary.via" class="stat-chip">Interface {{ lastPingSummary.via }}</span>
</div>
<div id="results" class="flex-1 overflow-y-auto rounded-2xl bg-black/80 text-emerald-300 font-mono text-xs p-3 space-y-1 shadow-inner border border-zinc-900">
<div v-if="pingResults.length === 0" class="text-emerald-500/80">No pings yet. Start a run to collect RTT data.</div>
<div v-for="(pingResult, index) in pingResults" :key="`${index}-${pingResult}`" class="whitespace-pre-wrap">{{ pingResult }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -61,9 +87,13 @@
<script>
import {CanceledError} from "axios";
import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: 'PingPage',
components: {
MaterialDesignIcon,
},
data() {
return {
isRunning: false,
@@ -72,6 +102,7 @@ export default {
seq: 0,
pingResults: [],
abortController: null,
lastPingSummary: null,
};
},
beforeUnmount() {
@@ -116,10 +147,13 @@ export default {
},
async stop() {
this.isRunning = false;
this.abortController.abort();
if(this.abortController){
this.abortController.abort();
}
},
async clear() {
this.pingResults = [];
this.lastPingSummary = null;
},
async sleep(millis) {
return new Promise((resolve, reject) => setTimeout(resolve, millis));
@@ -168,6 +202,15 @@ export default {
// update ui
this.addPingResult(info.join(" "));
this.lastPingSummary = {
duration: rttDurationString,
hopsThere: pingResult.hops_there,
hopsBack: pingResult.hops_back,
rssi: pingResult.rssi,
snr: pingResult.snr,
quality: pingResult.quality,
via: pingResult.receiving_interface,
};
} catch(e) {
@@ -181,6 +224,9 @@ export default {
// add ping error to results
const message = e.response?.data?.message ?? e;
this.addPingResult(`seq=${this.seq} error=${message}`);
this.lastPingSummary = {
error: typeof message === "string" ? message : JSON.stringify(message),
};
}
},

View File

@@ -145,7 +145,7 @@ export default {
}
// confirm user wants to update their icon
if(!confirm("Are you sure you want to set this as your profile icon?")){
if(!await DialogUtils.confirm("Are you sure you want to set this as your profile icon?")){
return;
}
@@ -160,7 +160,7 @@ export default {
async removeProfileIcon() {
// confirm user wants to remove their icon
if(!confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){
if(!await DialogUtils.confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){
return;
}

View File

@@ -0,0 +1,291 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gray-50 dark:bg-zinc-950">
<!-- search and sort -->
<div v-if="propagationNodes.length > 0" class="flex flex-col sm:flex-row gap-2 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 px-4 py-3">
<input v-model="searchTerm" type="text" :placeholder="`Search ${propagationNodes.length} Propagation Nodes...`" class="flex-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500">
<select v-model="sortBy" class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2 shadow-sm transition-all min-w-[180px]">
<option value="name">Sort by Name</option>
<option value="name-desc">Sort by Name (Z-A)</option>
<option value="recent">Sort by Recent</option>
<option value="oldest">Sort by Oldest</option>
<option value="preferred">Preferred First</option>
</select>
</div>
<!-- propagation nodes -->
<div class="h-full overflow-y-auto px-4 py-4">
<div v-if="paginatedNodes.length > 0" class="space-y-3 w-full">
<div v-for="propagationNode of paginatedNodes" :key="propagationNode.destination_hash" class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-sm hover:shadow-md transition-shadow overflow-hidden" :class="{ 'ring-2 ring-blue-500 dark:ring-blue-400': config.lxmf_preferred_propagation_node_destination_hash === propagationNode.destination_hash }">
<div class="p-4 flex items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<div class="font-semibold text-gray-900 dark:text-zinc-100 truncate">{{ propagationNode.operator_display_name ?? "Unknown Operator" }}</div>
<span v-if="config.lxmf_preferred_propagation_node_destination_hash === propagationNode.destination_hash" class="inline-flex items-center gap-1 rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs font-semibold text-blue-700 dark:text-blue-300">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
Preferred
</span>
<span v-if="propagationNode.is_propagation_enabled === false" class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2 py-0.5 text-xs font-semibold text-red-700 dark:text-red-300">
Disabled
</span>
</div>
<div class="text-sm text-gray-600 dark:text-zinc-400 font-mono truncate"><{{ propagationNode.destination_hash }}></div>
<div class="text-xs text-gray-500 dark:text-zinc-500 mt-1">Announced {{ formatTimeAgo(propagationNode.updated_at) }}</div>
</div>
<div class="flex-shrink-0">
<button v-if="config.lxmf_preferred_propagation_node_destination_hash === propagationNode.destination_hash" @click="stopUsingPropagationNode" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
Stop Using
</button>
<button v-else @click="usePropagationNode(propagationNode.destination_hash)" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
Set as Preferred
</button>
</div>
</div>
</div>
</div>
<!-- pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between mt-6 pt-4 border-t border-gray-200 dark:border-zinc-800">
<div class="text-sm text-gray-600 dark:text-zinc-400">
Showing {{ startIndex + 1 }}-{{ endIndex }} of {{ sortedAndSearchedPropagationNodes.length }}
</div>
<div class="flex items-center gap-2">
<button @click="currentPage = Math.max(1, currentPage - 1)" :disabled="currentPage === 1" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed px-3 py-2 text-sm font-medium text-gray-700 dark:text-zinc-300 shadow-sm transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Previous
</button>
<div class="flex items-center gap-1">
<button v-for="page in visiblePages" :key="page" @click="currentPage = page" type="button" :class="[ page === currentPage ? 'bg-blue-600 text-white dark:bg-blue-600' : 'bg-white dark:bg-zinc-900 text-gray-700 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-800' ]" class="w-10 h-10 rounded-xl border border-gray-200 dark:border-zinc-800 text-sm font-medium shadow-sm transition-colors">
{{ page }}
</button>
</div>
<button @click="currentPage = Math.min(totalPages, currentPage + 1)" :disabled="currentPage === totalPages" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed px-3 py-2 text-sm font-medium text-gray-700 dark:text-zinc-300 shadow-sm transition-colors">
Next
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</button>
</div>
</div>
<div v-else-if="sortedAndSearchedPropagationNodes.length === 0" class="flex h-full">
<div class="mx-auto my-auto text-center leading-5 text-gray-900 dark:text-gray-100">
<!-- no propagation nodes at all -->
<div v-if="propagationNodes.length === 0" class="flex flex-col">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z" />
</svg>
</div>
<div class="font-semibold">No Propagation Nodes</div>
<div>Check back later, once someone has announced.</div>
<div class="mt-4">
<button @click="loadPropagationNodes" type="button" class="inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
Reload
</button>
</div>
</div>
<!-- is searching, but no results -->
<div v-if="searchTerm !== '' && propagationNodes.length > 0" class="flex flex-col">
<div class="mx-auto mb-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<div class="font-semibold">No Search Results</div>
<div>Your search didn't match any Propagation Nodes!</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection";
export default {
name: 'PropagationNodesPage',
data() {
return {
searchTerm: "",
sortBy: "preferred",
propagationNodes: [],
config: {
lxmf_preferred_propagation_node_destination_hash: null,
},
currentPage: 1,
itemsPerPage: 20,
};
},
beforeUnmount() {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
this.getConfig();
this.loadPropagationNodes();
},
methods: {
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
switch(json.type){
case 'config': {
this.config = json.config;
break;
}
}
},
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch(e) {
// do nothing if failed to load config
console.log(e);
}
},
async updateConfig(config) {
try {
const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config;
} catch(e) {
alert("Failed to save config!");
console.log(e);
}
},
async loadPropagationNodes() {
try {
const response = await window.axios.get(`/api/v1/lxmf/propagation-nodes`, {
params: {
limit: 500,
},
});
this.propagationNodes = response.data.lxmf_propagation_nodes;
} catch(e) {
// do nothing if failed to load
}
},
async usePropagationNode(destination_hash) {
await this.updateConfig({
lxmf_preferred_propagation_node_destination_hash: destination_hash,
});
},
async stopUsingPropagationNode() {
await this.updateConfig({
lxmf_preferred_propagation_node_destination_hash: null,
});
},
formatTimeAgo: function(datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
},
watch: {
searchTerm() {
this.currentPage = 1;
},
sortBy() {
this.currentPage = 1;
},
},
computed: {
searchedPropagationNodes() {
return this.propagationNodes.filter((propagationNode) => {
const search = this.searchTerm.toLowerCase();
const matchesOperatorDisplayName = propagationNode.operator_display_name?.toLowerCase()?.includes(search) ?? false;
const matchesDestinationHash = propagationNode.destination_hash.toLowerCase().includes(search);
return matchesOperatorDisplayName || matchesDestinationHash;
});
},
sortedAndSearchedPropagationNodes() {
let nodes = [...this.searchedPropagationNodes];
switch(this.sortBy) {
case "name":
nodes.sort((a, b) => {
const nameA = (a.operator_display_name ?? "Unknown Operator").toLowerCase();
const nameB = (b.operator_display_name ?? "Unknown Operator").toLowerCase();
return nameA.localeCompare(nameB);
});
break;
case "name-desc":
nodes.sort((a, b) => {
const nameA = (a.operator_display_name ?? "Unknown Operator").toLowerCase();
const nameB = (b.operator_display_name ?? "Unknown Operator").toLowerCase();
return nameB.localeCompare(nameA);
});
break;
case "recent":
nodes.sort((a, b) => {
const timeA = new Date(a.updated_at).getTime();
const timeB = new Date(b.updated_at).getTime();
return timeB - timeA;
});
break;
case "oldest":
nodes.sort((a, b) => {
const timeA = new Date(a.updated_at).getTime();
const timeB = new Date(b.updated_at).getTime();
return timeA - timeB;
});
break;
case "preferred":
default:
nodes.sort((a, b) => {
const aIsPreferred = this.config.lxmf_preferred_propagation_node_destination_hash === a.destination_hash;
const bIsPreferred = this.config.lxmf_preferred_propagation_node_destination_hash === b.destination_hash;
if(aIsPreferred && !bIsPreferred) return -1;
if(!aIsPreferred && bIsPreferred) return 1;
const timeA = new Date(a.updated_at).getTime();
const timeB = new Date(b.updated_at).getTime();
return timeB - timeA;
});
break;
}
return nodes;
},
totalPages() {
return Math.ceil(this.sortedAndSearchedPropagationNodes.length / this.itemsPerPage);
},
startIndex() {
return (this.currentPage - 1) * this.itemsPerPage;
},
endIndex() {
return Math.min(this.startIndex + this.itemsPerPage, this.sortedAndSearchedPropagationNodes.length);
},
paginatedNodes() {
return this.sortedAndSearchedPropagationNodes.slice(this.startIndex, this.endIndex);
},
visiblePages() {
const pages = [];
const maxVisible = 5;
let start = Math.max(1, this.currentPage - Math.floor(maxVisible / 2));
let end = Math.min(this.totalPages, start + maxVisible - 1);
if(end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1);
}
for(let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
},
},
}
</script>

View File

@@ -0,0 +1,472 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900">
<div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-6xl mx-auto">
<!-- hero card -->
<div class="bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl p-5 md:p-6">
<div class="flex flex-col md:flex-row md:items-center gap-4">
<div class="flex-1 space-y-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Profile</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ config.display_name }}</div>
<div class="text-sm text-gray-600 dark:text-gray-300">Manage your identity, transport participation and LXMF defaults.</div>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<button @click="copyValue(config.identity_hash, 'Identity Hash')" type="button" class="inline-flex items-center justify-center gap-x-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-zinc-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-400/70 transition">
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
Identity
</button>
<button @click="copyValue(config.lxmf_address_hash, 'LXMF Address')" type="button" class="inline-flex items-center justify-center gap-x-2 rounded-xl bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 px-4 py-2 text-sm font-semibold text-white shadow hover:shadow-md transition">
<MaterialDesignIcon icon-name="account-plus" class="w-4 h-4"/>
LXMF Address
</button>
</div>
</div>
<transition name="fade">
<div v-if="copyToast" class="mt-3 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200 px-3 py-1 text-xs inline-flex items-center gap-2">
{{ copyToast }}
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-ping"></span>
</div>
</transition>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-4 text-sm text-gray-600 dark:text-gray-300">
<div class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70">
<div class="text-xs uppercase tracking-wide">Theme</div>
<div class="font-semibold text-gray-900 dark:text-white capitalize">{{ config.theme }} mode</div>
</div>
<div class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70">
<div class="text-xs uppercase tracking-wide">Transport</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ config.is_transport_enabled ? 'Enabled' : 'Disabled' }}</div>
</div>
<div class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70">
<div class="text-xs uppercase tracking-wide">Propagation</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ config.lxmf_local_propagation_node_enabled ? 'Local node running' : 'Client-only' }}</div>
</div>
</div>
<div class="grid gap-3 mt-4 text-sm text-gray-700 dark:text-gray-200 sm:grid-cols-2">
<div class="address-card">
<div class="address-card__label">Identity Hash</div>
<div class="address-card__value monospace-field">{{ config.identity_hash }}</div>
<button @click="copyValue(config.identity_hash, 'Identity Hash')" type="button" class="address-card__action">
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
Copy
</button>
</div>
<div class="address-card">
<div class="address-card__label">LXMF Address</div>
<div class="address-card__value monospace-field">{{ config.lxmf_address_hash }}</div>
<button @click="copyValue(config.lxmf_address_hash, 'LXMF Address')" type="button" class="address-card__action">
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
Copy
</button>
</div>
</div>
</div>
<!-- settings grid -->
<div class="grid gap-4 lg:grid-cols-2">
<!-- Appearance -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Personalise</div>
<h2>Appearance</h2>
<p>Switch between light and dark presets anytime.</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<select v-model="config.theme" @change="onThemeChange" class="input-field">
<option value="light">Light Theme</option>
<option value="dark">Dark Theme</option>
</select>
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-300 border border-dashed border-gray-200 dark:border-zinc-800 rounded-2xl px-3 py-2">
<div>Live preview updates instantly.</div>
<span class="inline-flex items-center gap-1 text-blue-500 dark:text-blue-300 text-xs font-semibold uppercase">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
Realtime
</span>
</div>
</div>
</section>
<!-- Transport -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Reticulum</div>
<h2>Transport Mode</h2>
<p>Relay paths and traffic for nearby peers.</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<label class="setting-toggle">
<input type="checkbox" v-model="config.is_transport_enabled" @change="onIsTransportEnabledChange">
<span class="setting-toggle__label">
<span class="setting-toggle__title">Enable Transport Mode</span>
<span class="setting-toggle__description">Route announces, respond to path requests and help your mesh stay online.</span>
<span class="setting-toggle__hint">Requires restart after toggling.</span>
</span>
</label>
</div>
</section>
<!-- Interfaces -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Adapters</div>
<h2>Interfaces</h2>
<p>Show curated community configs inside the interface wizard.</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<label class="setting-toggle">
<input type="checkbox" v-model="config.show_suggested_community_interfaces" @change="onShowSuggestedCommunityInterfacesChange">
<span class="setting-toggle__label">
<span class="setting-toggle__title">Show Community Interfaces</span>
<span class="setting-toggle__description">Surface community-maintained presets while adding new interfaces.</span>
</span>
</label>
</div>
</section>
<!-- Messages -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Reliability</div>
<h2>Messages</h2>
<p>Control how MeshChat retries or escalates failed deliveries.</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<label class="setting-toggle">
<input type="checkbox" v-model="config.auto_resend_failed_messages_when_announce_received" @change="onAutoResendFailedMessagesWhenAnnounceReceivedChange">
<span class="setting-toggle__label">
<span class="setting-toggle__title">Auto resend when peer announces</span>
<span class="setting-toggle__description">Failed messages automatically retry once the destination broadcasts again.</span>
</span>
</label>
<label class="setting-toggle">
<input type="checkbox" v-model="config.allow_auto_resending_failed_messages_with_attachments" @change="onAllowAutoResendingFailedMessagesWithAttachmentsChange">
<span class="setting-toggle__label">
<span class="setting-toggle__title">Allow retries with attachments</span>
<span class="setting-toggle__description">Large payloads will also be retried (useful when both peers have high limits).</span>
</span>
</label>
<label class="setting-toggle">
<input type="checkbox" v-model="config.auto_send_failed_messages_to_propagation_node" @change="onAutoSendFailedMessagesToPropagationNodeChange">
<span class="setting-toggle__label">
<span class="setting-toggle__title">Auto fall back to propagation node</span>
<span class="setting-toggle__description">Failed direct deliveries are queued on your preferred propagation node.</span>
</span>
</label>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Inbound Message Stamp Cost</div>
<input v-model.number="config.lxmf_inbound_stamp_cost" @input="onLxmfInboundStampCostChange" type="number" min="1" max="254" placeholder="8" class="input-field">
<div class="text-xs text-gray-600 dark:text-gray-400">
Require proof-of-work stamps for direct delivery messages sent to you. Higher values require more computational work from senders. Range: 1-254. Default: 8.
</div>
</div>
</div>
</section>
<!-- Propagation nodes -->
<section class="glass-card lg:col-span-2">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">LXMF</div>
<h2>Propagation Nodes</h2>
<p>Keep conversations flowing even when peers are offline.</p>
</div>
<RouterLink :to="{ name: 'propagation-nodes' }" class="primary-chip">
Browse Nodes
</RouterLink>
</header>
<div class="glass-card__body space-y-5">
<div class="info-callout">
<ul class="list-disc list-inside space-y-1 text-sm">
<li>Propagation nodes hold messages securely until recipients sync again.</li>
<li>Nodes peer with each other to distribute encrypted payloads.</li>
<li>Most nodes retain data ~30 days, then discard undelivered items.</li>
</ul>
</div>
<label class="setting-toggle">
<input type="checkbox" v-model="config.lxmf_local_propagation_node_enabled" @change="onLxmfLocalPropagationNodeEnabledChange">
<span class="setting-toggle__label">
<span class="setting-toggle__title">Run a local propagation node</span>
<span class="setting-toggle__description">MeshChat will announce and maintain a node using this local destination hash.</span>
<span class="setting-toggle__hint monospace-field">{{ config.lxmf_local_propagation_node_address_hash || '—' }}</span>
</span>
</label>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Preferred Propagation Node</div>
<input v-model="config.lxmf_preferred_propagation_node_destination_hash" @input="onLxmfPreferredPropagationNodeDestinationHashChange" type="text" placeholder="Destination hash, e.g. a39610c89d18bb48c73e429582423c24" class="input-field monospace-field">
<div class="text-xs text-gray-600 dark:text-gray-400">Messages fallback to this node whenever direct delivery fails.</div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Auto Sync Interval</div>
<select v-model="config.lxmf_preferred_propagation_node_auto_sync_interval_seconds" @change="onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange" class="input-field">
<option value="0">Disabled</option>
<option value="900">Every 15 Minutes</option>
<option value="1800">Every 30 Minutes</option>
<option value="3600">Every 1 Hour</option>
<option value="10800">Every 3 Hours</option>
<option value="21600">Every 6 Hours</option>
<option value="43200">Every 12 Hours</option>
<option value="86400">Every 24 Hours</option>
</select>
<div class="text-xs text-gray-600 dark:text-gray-400">
<span v-if="config.lxmf_preferred_propagation_node_last_synced_at">Last synced {{ formatSecondsAgo(config.lxmf_preferred_propagation_node_last_synced_at) }} ago.</span>
<span v-else>Last synced: never.</span>
</div>
</div>
<div v-if="config.lxmf_local_propagation_node_enabled" class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Propagation Node Stamp Cost</div>
<input v-model.number="config.lxmf_propagation_node_stamp_cost" @input="onLxmfPropagationNodeStampCostChange" type="number" min="13" max="254" placeholder="16" class="input-field">
<div class="text-xs text-gray-600 dark:text-gray-400">
Require proof-of-work stamps for messages propagated through your node. Higher values require more computational work. Range: 13-254. Default: 16. <strong>Note:</strong> Changing this requires restarting the app.
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
</template>
<script>
import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection";
import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: 'SettingsPage',
components: {
MaterialDesignIcon,
},
data() {
return {
config: {
auto_resend_failed_messages_when_announce_received: null,
allow_auto_resending_failed_messages_with_attachments: null,
auto_send_failed_messages_to_propagation_node: null,
show_suggested_community_interfaces: null,
lxmf_local_propagation_node_enabled: null,
lxmf_preferred_propagation_node_destination_hash: null,
},
copyToast: null,
copyToastTimeout: null,
};
},
beforeUnmount() {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
clearTimeout(this.copyToastTimeout);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
this.getConfig();
},
methods: {
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
switch(json.type){
case 'config': {
this.config = json.config;
break;
}
}
},
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch(e) {
// do nothing if failed to load config
console.log(e);
}
},
async updateConfig(config) {
try {
const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config;
} catch(e) {
alert("Failed to save config!");
console.log(e);
}
},
async copyValue(value, label) {
if(!value){
DialogUtils.alert(`Nothing to copy for ${label}`);
return;
}
try {
await navigator.clipboard.writeText(value);
this.showCopyToast(`${label} copied to clipboard`);
} catch(e) {
DialogUtils.alert(`${label}: ${value}`);
}
},
showCopyToast(message) {
this.copyToast = message;
clearTimeout(this.copyToastTimeout);
this.copyToastTimeout = setTimeout(() => {
this.copyToast = null;
}, 2500);
},
async onThemeChange() {
await this.updateConfig({
"theme": this.config.theme,
});
},
async onAutoResendFailedMessagesWhenAnnounceReceivedChange() {
await this.updateConfig({
"auto_resend_failed_messages_when_announce_received": this.config.auto_resend_failed_messages_when_announce_received,
});
},
async onAllowAutoResendingFailedMessagesWithAttachmentsChange() {
await this.updateConfig({
"allow_auto_resending_failed_messages_with_attachments": this.config.allow_auto_resending_failed_messages_with_attachments,
});
},
async onAutoSendFailedMessagesToPropagationNodeChange() {
await this.updateConfig({
"auto_send_failed_messages_to_propagation_node": this.config.auto_send_failed_messages_to_propagation_node,
});
},
async onShowSuggestedCommunityInterfacesChange() {
await this.updateConfig({
"show_suggested_community_interfaces": this.config.show_suggested_community_interfaces,
});
},
async onLxmfPreferredPropagationNodeDestinationHashChange() {
await this.updateConfig({
"lxmf_preferred_propagation_node_destination_hash": this.config.lxmf_preferred_propagation_node_destination_hash,
});
},
async onLxmfLocalPropagationNodeEnabledChange() {
await this.updateConfig({
"lxmf_local_propagation_node_enabled": this.config.lxmf_local_propagation_node_enabled,
});
},
async onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange() {
await this.updateConfig({
"lxmf_preferred_propagation_node_auto_sync_interval_seconds": this.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds,
});
},
async onLxmfInboundStampCostChange() {
await this.updateConfig({
"lxmf_inbound_stamp_cost": this.config.lxmf_inbound_stamp_cost,
});
},
async onLxmfPropagationNodeStampCostChange() {
await this.updateConfig({
"lxmf_propagation_node_stamp_cost": this.config.lxmf_propagation_node_stamp_cost,
});
},
async onIsTransportEnabledChange() {
if(this.config.is_transport_enabled){
try {
const response = await window.axios.post("/api/v1/reticulum/enable-transport");
DialogUtils.alert(response.data.message);
} catch(e) {
DialogUtils.alert("Failed to enable transport mode!");
console.log(e);
}
} else {
try {
const response = await window.axios.post("/api/v1/reticulum/disable-transport");
DialogUtils.alert(response.data.message);
} catch(e) {
DialogUtils.alert("Failed to disable transport mode!");
console.log(e);
}
}
},
formatSecondsAgo: function(seconds) {
return Utils.formatSecondsAgo(seconds);
},
},
}
</script>
<style scoped>
.glass-card {
@apply bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg flex flex-col;
}
.glass-card__header {
@apply flex items-center justify-between gap-3 px-4 py-4 border-b border-gray-100/70 dark:border-zinc-800/80;
}
.glass-card__header h2 {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.glass-card__header p {
@apply text-sm text-gray-600 dark:text-gray-400;
}
.glass-card__eyebrow {
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
}
.glass-card__body {
@apply px-4 py-4 text-gray-900 dark:text-gray-100;
}
.input-field {
@apply bg-gray-50/90 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-2xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-2.5 text-gray-900 dark:text-gray-100 transition;
}
.setting-toggle {
@apply flex items-start gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 px-3 py-3;
}
.setting-toggle input[type="checkbox"] {
@apply w-4 h-4 mt-1 rounded border-gray-300 dark:border-zinc-600 text-blue-600 focus:ring-blue-500;
}
.setting-toggle__label {
@apply flex-1 flex flex-col gap-0.5;
}
.setting-toggle__title {
@apply text-sm font-semibold text-gray-900 dark:text-white;
}
.setting-toggle__description {
@apply text-sm text-gray-600 dark:text-gray-300;
}
.setting-toggle__hint {
@apply text-xs text-gray-500 dark:text-gray-400;
}
.primary-chip {
@apply inline-flex items-center gap-x-1 rounded-full bg-blue-600/90 px-4 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
}
.info-callout {
@apply rounded-2xl border border-blue-100 dark:border-blue-900/40 bg-blue-50/60 dark:bg-blue-900/20 px-3 py-3 text-blue-900 dark:text-blue-100;
}
.monospace-field {
font-family: "Roboto Mono", monospace;
}
.address-card {
@apply relative border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white/80 dark:bg-zinc-900/70 p-4 space-y-2;
}
.address-card__label {
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
}
.address-card__value {
@apply text-sm text-gray-900 dark:text-white break-words pr-16;
}
.address-card__action {
@apply absolute top-3 right-3 inline-flex items-center gap-1 rounded-full border border-gray-200 dark:border-zinc-700 px-3 py-1 text-xs font-semibold text-gray-700 dark:text-gray-100 bg-white/70 dark:bg-zinc-900/60 hover:border-blue-400 dark:hover:border-blue-500 transition;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900">
<div class="overflow-y-auto space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div class="glass-card space-y-3">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Utilities</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">Power tools for operators</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Diagnostics and firmware helpers ship with MeshChat so you can troubleshoot peers without leaving the console.
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<RouterLink :to="{ name: 'ping' }" class="tool-card glass-card">
<div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
<MaterialDesignIcon icon-name="radar" class="w-6 h-6"/>
</div>
<div class="flex-1">
<div class="tool-card__title">Ping</div>
<div class="tool-card__description">Latency test for any LXMF destination hash with live status.</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron"/>
</RouterLink>
<a target="_blank" href="/rnode-flasher/index.html" class="tool-card glass-card">
<div class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200">
<img src="/rnode-flasher/reticulum_logo_512.png" class="w-8 h-8 rounded-full" alt="RNode"/>
</div>
<div class="flex-1">
<div class="tool-card__title">RNode Flasher</div>
<div class="tool-card__description">Flash and update RNode adapters without touching the command line.</div>
</div>
<MaterialDesignIcon icon-name="open-in-new" class="tool-card__chevron"/>
</a>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: 'ToolsPage',
components: {
MaterialDesignIcon,
},
}
</script>
<style scoped>
.tool-card {
@apply flex items-center gap-4 hover:border-blue-400 dark:hover:border-blue-500 transition cursor-pointer;
}
.tool-card__icon {
@apply w-12 h-12 rounded-2xl flex items-center justify-center;
}
.tool-card__title {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.tool-card__description {
@apply text-sm text-gray-600 dark:text-gray-300;
}
.tool-card__chevron {
@apply w-5 h-5 text-gray-400;
}
</style>

View File

@@ -9,14 +9,6 @@
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
<title>Reticulum MeshChat</title>
<!-- codec2 -->
<script src="assets/js/codec2-emscripten/c2enc.js"></script>
<script src="assets/js/codec2-emscripten/c2dec.js"></script>
<script src="assets/js/codec2-emscripten/sox.js"></script>
<script src="assets/js/codec2-emscripten/codec2-lib.js"></script>
<script src="assets/js/codec2-emscripten/wav-encoder.js"></script>
<script src="assets/js/codec2-emscripten/codec2-microphone-recorder.js"></script>
</head>
<body class="bg-gray-100">
<div id="app"></div>

View File

@@ -0,0 +1,62 @@
const codec2ScriptPaths = [
'/assets/js/codec2-emscripten/c2enc.js',
'/assets/js/codec2-emscripten/c2dec.js',
'/assets/js/codec2-emscripten/sox.js',
'/assets/js/codec2-emscripten/codec2-lib.js',
'/assets/js/codec2-emscripten/wav-encoder.js',
'/assets/js/codec2-emscripten/codec2-microphone-recorder.js',
];
let loadPromise = null;
function injectScript(src) {
if (typeof document === 'undefined') {
return Promise.resolve();
}
const attrName = 'data-codec2-src';
const loadedAttr = 'data-codec2-loaded';
const existing = document.querySelector(`script[${attrName}="${src}"]`);
if (existing) {
if (existing.getAttribute(loadedAttr) === 'true') {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true });
});
}
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.async = false;
script.setAttribute(attrName, src);
script.addEventListener('load', () => {
script.setAttribute(loadedAttr, 'true');
resolve();
});
script.addEventListener('error', () => {
script.remove();
reject(new Error(`Failed to load ${src}`));
});
document.head.appendChild(script);
});
}
export function ensureCodec2ScriptsLoaded() {
if (typeof window === 'undefined') {
return Promise.resolve();
}
if (!loadPromise) {
loadPromise = codec2ScriptPaths.reduce(
(chain, src) => chain.then(() => injectScript(src)),
Promise.resolve(),
);
}
return loadPromise;
}

View File

@@ -10,6 +10,16 @@ class DialogUtils {
}
}
static confirm(message) {
if(window.electron){
// running inside electron, use ipc confirm
return window.electron.confirm(message);
} else {
// running inside normal browser, use browser alert
return window.confirm(message);
}
}
static async prompt(message) {
if(window.electron){
// running inside electron, use ipc prompt

View File

@@ -0,0 +1,28 @@
class DownloadUtils {
static downloadFile(filename, blob) {
// create object url for blob
const objectUrl = URL.createObjectURL(blob);
// create hidden link element to download blob
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
link.style.display = "none";
document.body.append(link);
// click link to download file in browser
link.click();
// link element is no longer needed
link.remove();
// revoke object url to clear memory
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
}
}
export default DownloadUtils;

View File

@@ -2,6 +2,13 @@ import moment from "moment";
class Utils {
static formatDestinationHash(destinationHashHex) {
const bytesPerSide = 4;
const leftSide = destinationHashHex.substring(0, bytesPerSide * 2);
const rightSide = destinationHashHex.substring(destinationHashHex.length - bytesPerSide * 2);
return `<${leftSide}...${rightSide}>`
}
static formatBytes(bytes) {
if(bytes === 0){
@@ -18,6 +25,13 @@ class Utils {
}
static formatNumber(num) {
if(num === 0){
return '0';
}
return num.toLocaleString();
}
static parseSeconds(secondsToFormat) {
secondsToFormat = Number(secondsToFormat);
var days = Math.floor(secondsToFormat / (3600 * 24));
@@ -120,6 +134,22 @@ class Utils {
}
static formatBytesPerSecond(bytesPerSecond) {
if(bytesPerSecond === 0 || bytesPerSecond == null){
return '0 B/s';
}
const k = 1024;
const decimals = 1;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
}
static formatFrequency(hz) {
if(hz === 0 || hz == null){
@@ -143,7 +173,7 @@ class Utils {
static isInterfaceEnabled(iface) {
const rawValue = iface.enabled ?? iface.interface_enabled;
const value = rawValue?.toLowerCase();
const value = rawValue?.toString()?.toLowerCase();
return value === "on" || value === "yes" || value === "true";
}

View File

@@ -3,8 +3,18 @@ import mitt from 'mitt';
class WebSocketConnection {
constructor() {
this.emitter = mitt();
this.reconnect();
/**
* ping websocket server every 30 seconds
* this helps to prevent the underlying tcp connection from going stale when there's no traffic for a long time
*/
setInterval(() => {
this.ping();
}, 30000);
}
// add event listener
@@ -47,6 +57,16 @@ class WebSocketConnection {
}
}
ping() {
try {
this.send(JSON.stringify({
"type": "ping",
}));
} catch(e) {
// ignore error
}
}
}
export default new WebSocketConnection();

View File

@@ -4,6 +4,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
import vClickOutside from "click-outside-vue3";
import "./style.css";
import "./fonts/RobotoMonoNerdFont/font.css";
import { ensureCodec2ScriptsLoaded } from "./js/Codec2Loader";
import App from './components/App.vue';
@@ -46,7 +47,15 @@ const router = createRouter({
},
{
name: "messages",
path: '/messages',
path: '/messages/:destinationHash?',
props: true,
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
},
{
name: "messages-popout",
path: '/popout/messages/:destinationHash?',
props: true,
meta: { popoutType: "conversation", isPopout: true },
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
},
{
@@ -56,7 +65,15 @@ const router = createRouter({
},
{
name: "nomadnetwork",
path: '/nomadnetwork',
path: '/nomadnetwork/:destinationHash?',
props: true,
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
},
{
name: "nomadnetwork-popout",
path: '/popout/nomadnetwork/:destinationHash?',
props: true,
meta: { popoutType: "nomad", isPopout: true },
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
},
{
@@ -84,11 +101,21 @@ const router = createRouter({
path: '/tools',
component: defineAsyncComponent(() => import("./components/tools/ToolsPage.vue")),
},
{
name: "call",
path: '/call',
component: defineAsyncComponent(() => import("./components/call/CallPage.vue")),
},
],
})
createApp(App)
.use(router)
.use(vuetify)
.use(vClickOutside)
.mount('#app');
async function bootstrap() {
await ensureCodec2ScriptsLoaded();
createApp(App)
.use(router)
.use(vuetify)
.use(vClickOutside)
.mount('#app');
}
bootstrap();

View File

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

View File

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Some files were not shown because too many files have changed in this diff Show More