diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1acc26b6e4..a3400f3ffa 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -80,13 +80,23 @@ jobs:
runs-on: ubuntu-latest
outputs:
libs_tag: ${{ steps.libs_commit.outputs.tag }}
+ core_changed: ${{ steps.core.outputs.core }}
steps:
- name: Clone project
- if: github.event_name == 'push'
+ if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@v3
with:
fetch-depth: 2
+ - name: Check chat core change
+ id: core
+ if: github.event_name == 'pull_request'
+ env:
+ BASE: ${{ github.event.pull_request.base.sha }}
+ run: |
+ git fetch -q --depth=1 origin "$BASE"
+ python3 scripts/ci/packages_release.py changes "$BASE" | grep '^core=' >> "$GITHUB_OUTPUT"
+
- name: Build changelog
id: build_changelog
if: startsWith(github.ref, 'refs/tags/v')
@@ -122,35 +132,26 @@ jobs:
echo "version=$version" >> $GITHUB_OUTPUT
fi
- - name: Clone libs repo
- if: steps.check_version.outputs.version
- uses: actions/checkout@v3
- with:
- repository: ${{ github.repository }}-libs
- token: ${{ secrets.NODEJS_REPO_TOKEN }}
- path: libs-repo
-
- - name: Commit and tag libs release
+ - name: Record libs release
id: libs_commit
if: steps.check_version.outputs.version
- working-directory: libs-repo
+ env:
+ GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }}
+ LIBS_REPOSITORY: ${{ github.repository }}-libs
+ VERSION: ${{ steps.check_version.outputs.version }}
run: |
- tag=core-${{ steps.check_version.outputs.version }}
- printf 'version: %s\ncommit: %s\n' "${{ steps.check_version.outputs.version }}" "${{ github.sha }}" > core-release
- git config user.name "SimpleX Chat"
- git config user.email "build@simplex.chat"
- git add core-release
- git commit -m "$tag"
- git tag "$tag"
- git push --atomic origin HEAD "$tag"
- echo "tag=$tag" >> $GITHUB_OUTPUT
+ commit=$(python3 scripts/ci/packages_release.py record --core "$VERSION" --commit "$GITHUB_SHA")
+ echo "tag=core-$VERSION" >> "$GITHUB_OUTPUT"
+ echo "commit=$commit" >> "$GITHUB_OUTPUT"
+ # creates the tag on the commit that recorded the release
- name: Create libs release
if: steps.check_version.outputs.version
uses: softprops/action-gh-release@v2
with:
repository: ${{ github.repository }}-libs
tag_name: ${{ steps.libs_commit.outputs.tag }}
+ target_commitish: ${{ steps.libs_commit.outputs.commit }}
token: ${{ secrets.NODEJS_REPO_TOKEN }}
# =========================
@@ -300,8 +301,9 @@ jobs:
done
strip /out/simplex-chat
+ # Also for pull requests that change the chat core, for the package tests (packages-pr).
- name: Build library
- if: needs.maybe-release.outputs.libs_tag && matrix.libs
+ if: matrix.libs && (needs.maybe-release.outputs.libs_tag || (needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64'))
shell: docker exec -t builder sh -eu {0}
run: |
SKIP_VLC=1 scripts/desktop/build-lib-linux.sh
@@ -310,6 +312,14 @@ jobs:
find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-${{ matrix.arch }} -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \;
(cd release-libs/$name && zip -r ../$name.zip libs)
+ - name: Upload library artifact
+ if: matrix.libs && needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64'
+ uses: actions/upload-artifact@v7
+ with:
+ name: simplex-chat-libs-linux-${{ matrix.arch }}
+ path: release-libs/simplex-chat-libs-linux-${{ matrix.arch }}.zip
+ if-no-files-found: error
+
- name: Upload library
if: needs.maybe-release.outputs.libs_tag && matrix.libs
uses: softprops/action-gh-release@v2
@@ -837,3 +847,34 @@ jobs:
bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
+
+# =========================
+# Packages release
+# =========================
+
+# A new library release is tested with the Node.js and Python packages and updates them in a release PR.
+
+ packages:
+ needs: [maybe-release, build-linux, build-linux-postgres, build-macos, build-windows]
+ if: ${{ !cancelled() && needs.maybe-release.outputs.libs_tag }}
+ # the called workflow can't be granted more than its caller, and its publish job needs these
+ permissions:
+ contents: read
+ id-token: write
+ uses: ./.github/workflows/packages.yml
+ with:
+ libs_release: ${{ needs.maybe-release.outputs.libs_tag }}
+ secrets: inherit
+
+# A pull request that changes the chat core is tested with the Node.js and Python packages.
+
+ packages-pr:
+ needs: [maybe-release, build-linux]
+ # build-linux uploads the library before its chat tests run
+ if: ${{ !cancelled() && needs.maybe-release.outputs.core_changed }}
+ permissions:
+ contents: read
+ id-token: write
+ uses: ./.github/workflows/packages.yml
+ with:
+ libs_artifact: true
diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml
new file mode 100644
index 0000000000..f826ccbbfc
--- /dev/null
+++ b/.github/workflows/desktop-linux.yml
@@ -0,0 +1,500 @@
+name: desktop-linux
+
+# A push runs the jobs only when its head commit's message asks for them: see scripts/ci/desktop-linux/README.md.
+on:
+ push:
+ branches:
+ - "**"
+ # a squash merge's message lists the merged commits' subjects, with their tags
+ - "!master"
+ - "!stable"
+ workflow_dispatch:
+ inputs:
+ claude:
+ description: Also run the Claude job, which spends the Claude subscription's usage limits
+ type: boolean
+ default: false
+ backend:
+ description: Also start the test backend and test the released app
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+ actions: read
+
+concurrency:
+ # a push that asks for no run doesn't cancel one that does
+ group: desktop-linux-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:desktop]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]') }}
+ cancel-in-progress: true
+
+env:
+ GHC_VER: 9.6.3
+ CABAL_VER: 3.10.2.0
+ JAVA_VER: 17
+ CORE_PKG: desktop-core-linux-x86_64.tar.gz
+ BACKEND_PKG: desktop-linux-backend.tar.gz
+
+jobs:
+ plan:
+ name: choose jobs, find earlier builds
+ if: >-
+ github.event_name == 'workflow_dispatch' ||
+ contains(github.event.head_commit.message, '[app-ci]') ||
+ contains(github.event.head_commit.message, '[app-ci:desktop]') ||
+ contains(github.event.head_commit.message, '[claude-test]') ||
+ contains(github.event.head_commit.message, '[claude-test:desktop]')
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ outputs:
+ run-claude: ${{ steps.jobs.outputs.claude }}
+ run-backend: ${{ steps.jobs.outputs.backend }}
+ backend: ${{ steps.names.outputs.backend }}
+ backend-run: ${{ steps.backend.outputs.run-id }}
+ steps:
+ - name: Choose jobs
+ id: jobs
+ env:
+ CLAUDE: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]') }}
+ BACKEND: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }}
+ run: |
+ echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT"
+ echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT"
+
+ - name: Checkout code
+ if: steps.jobs.outputs.backend == 'true'
+ uses: actions/checkout@v5
+
+ # artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded
+ - name: Artifact names
+ id: names
+ if: steps.jobs.outputs.backend == 'true'
+ run: echo "backend=desktop-linux-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/desktop-linux/build-backend.sh') }}" >> "$GITHUB_OUTPUT"
+
+ - name: Find backend
+ id: backend
+ if: steps.jobs.outputs.backend == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.backend }}"
+
+ backend:
+ name: test backend for the app
+ needs: plan
+ if: needs.plan.outputs.run-backend == 'true' && needs.plan.outputs.backend-run == ''
+ runs-on: ubuntu-24.04
+ timeout-minutes: 150
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+
+ - name: Show runner
+ run: |
+ lsb_release -ds
+ nproc
+ free -h
+ df -h /
+
+ - name: Setup Haskell
+ id: haskell
+ uses: simplex-chat/setup-haskell-action@v2
+ with:
+ ghc-version: ${{ env.GHC_VER }}
+ cabal-version: ${{ env.CABAL_VER }}
+
+ # before the first backend store exists, the core library's has most of the same Hackage dependencies
+ - name: Restore cabal store
+ id: cabal-cache
+ uses: actions/cache/restore@v5
+ with:
+ path: |
+ ${{ steps.haskell.outputs.cabal-store }}
+ dist-newstyle
+ key: desktop-linux-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }}
+ restore-keys: |
+ desktop-linux-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-
+ desktop-linux-backend-store-ghc${{ env.GHC_VER }}-
+ desktop-linux-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-
+
+ - name: Install libraries
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev
+
+ - name: Build backend
+ run: scripts/ci/desktop-linux/build-backend.sh ~/backend/${{ env.BACKEND_PKG }}
+
+ - name: Save cabal store
+ if: ${{ !cancelled() }}
+ uses: actions/cache/save@v5
+ with:
+ path: |
+ ${{ steps.haskell.outputs.cabal-store }}
+ dist-newstyle
+ key: ${{ steps.cabal-cache.outputs.cache-primary-key }}
+
+ - name: Upload backend
+ uses: actions/upload-artifact@v6
+ with:
+ name: ${{ needs.plan.outputs.backend }}
+ path: ~/backend/${{ env.BACKEND_PKG }}
+ compression-level: 0
+ retention-days: 30
+ if-no-files-found: error
+
+ core:
+ name: core library for Linux
+ needs: plan
+ runs-on: ubuntu-24.04
+ timeout-minutes: 150
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+
+ - name: Show runner
+ run: |
+ lsb_release -ds
+ nproc
+ free -h
+ df -h /
+
+ - name: Restore core library
+ id: core-cache
+ uses: actions/cache/restore@v5
+ with:
+ path: ~/core/${{ env.CORE_PKG }}
+ key: desktop-linux-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'scripts/desktop/build-lib-linux.sh', 'scripts/ci/desktop-linux/build-core.sh') }}
+
+ - name: Setup Haskell
+ id: haskell
+ if: steps.core-cache.outputs.cache-hit != 'true'
+ uses: simplex-chat/setup-haskell-action@v2
+ with:
+ ghc-version: ${{ env.GHC_VER }}
+ cabal-version: ${{ env.CABAL_VER }}
+
+ - name: Restore cabal store
+ id: cabal-cache
+ if: steps.core-cache.outputs.cache-hit != 'true'
+ uses: actions/cache/restore@v5
+ with:
+ path: |
+ ${{ steps.haskell.outputs.cabal-store }}
+ dist-newstyle
+ key: desktop-linux-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }}
+ restore-keys: |
+ desktop-linux-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-
+ desktop-linux-core-store-ghc${{ env.GHC_VER }}-
+
+ - name: Install libraries
+ if: steps.core-cache.outputs.cache-hit != 'true'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev patchelf
+
+ - name: Build core library
+ if: steps.core-cache.outputs.cache-hit != 'true'
+ run: scripts/ci/desktop-linux/build-core.sh ~/core/${{ env.CORE_PKG }}
+
+ - name: Save cabal store
+ if: ${{ !cancelled() && steps.core-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v5
+ with:
+ path: |
+ ${{ steps.haskell.outputs.cabal-store }}
+ dist-newstyle
+ key: ${{ steps.cabal-cache.outputs.cache-primary-key }}
+
+ - name: Save core library
+ if: steps.core-cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@v5
+ with:
+ path: ~/core/${{ env.CORE_PKG }}
+ key: ${{ steps.core-cache.outputs.cache-primary-key }}
+
+ - name: Upload core library
+ uses: actions/upload-artifact@v6
+ with:
+ name: desktop-core-linux-x86_64
+ path: ~/core/${{ env.CORE_PKG }}
+ compression-level: 0
+ retention-days: 3
+ if-no-files-found: error
+
+ app:
+ name: desktop app under Xvfb
+ needs: [plan, core, backend]
+ # backend is skipped when it isn't asked for or an earlier run built it
+ if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 60
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+ with:
+ submodules: recursive
+
+ - name: Show runner
+ run: |
+ lsb_release -ds
+ nproc
+ free -h
+ df -h /
+ echo "ANDROID_HOME=$ANDROID_HOME"
+ ls "$ANDROID_HOME/platforms" "$ANDROID_HOME/build-tools" || true
+
+ - name: Install tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
+ dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
+
+ - name: Download core library
+ uses: actions/download-artifact@v7
+ with:
+ name: desktop-core-linux-x86_64
+ path: ~/core
+
+ - name: Install core library
+ id: core
+ run: scripts/ci/desktop-linux/install-core.sh ~/core/${{ env.CORE_PKG }}
+
+ - name: Download backend
+ if: needs.plan.outputs.run-backend == 'true'
+ uses: actions/download-artifact@v7
+ with:
+ name: ${{ needs.plan.outputs.backend }}
+ path: ~/backend
+ run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
+ github-token: ${{ github.token }}
+
+ - name: Start test backend
+ id: backend
+ if: needs.plan.outputs.run-backend == 'true'
+ timeout-minutes: 30
+ run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
+
+ # the app and its tests are still built when the backend fails
+ - name: Setup Java
+ if: ${{ !cancelled() && steps.core.outcome == 'success' }}
+ uses: actions/setup-java@v5
+ with:
+ distribution: corretto
+ java-version: ${{ env.JAVA_VER }}
+ cache: gradle
+
+ - name: Build app
+ id: build
+ if: ${{ !cancelled() && steps.core.outcome == 'success' }}
+ working-directory: apps/multiplatform
+ run: |
+ ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :desktop:createDistributable
+ app=release/main/app/simplex
+ ls -la "$app/bin" "$app/lib/app/resources"
+ du -sh "$app"
+ LD_LIBRARY_PATH="$app/lib/app/resources" ldd "$app/lib/app/resources/libapp-lib.so"
+
+ - name: Run desktop tests
+ if: ${{ !cancelled() && steps.build.outcome == 'success' }}
+ working-directory: apps/multiplatform
+ run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :common:desktopTest
+
+ - name: Report desktop tests
+ if: ${{ !cancelled() && steps.build.outcome == 'success' }}
+ run: scripts/ci/desktop-linux/test-summary.py apps/multiplatform/common/build/test-results/desktopTest
+
+ - name: Run smoke test
+ if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome != 'failure' }}
+ env:
+ BACKEND: ${{ steps.backend.outcome }}
+ run: |
+ if [ "$BACKEND" = success ]; then
+ export NAMES_SERVER=$(cat "$RUNNER_TEMP/backend/names-server")
+ cases=backend
+ fi
+ scripts/ci/desktop-linux/smoke-test.sh apps/multiplatform/release/main/app/simplex "$RUNNER_TEMP/smoke-test" ${cases:-}
+
+ - name: Stop test backend
+ if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
+ run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
+
+ - name: Upload smoke test results
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v6
+ with:
+ name: desktop-smoke-test
+ path: ${{ runner.temp }}/smoke-test
+ retention-days: 3
+ if-no-files-found: warn
+
+ - name: Upload desktop test report
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v6
+ with:
+ name: desktop-test-report
+ path: |
+ apps/multiplatform/common/build/reports/tests/desktopTest
+ apps/multiplatform/common/build/test-results/desktopTest
+ retention-days: 3
+ if-no-files-found: warn
+
+ - name: Upload backend logs
+ if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
+ uses: actions/upload-artifact@v6
+ with:
+ name: desktop-backend-logs
+ path: ${{ runner.temp }}/backend/*.log
+ retention-days: 3
+ if-no-files-found: warn
+
+ # Claude Code repeats the smoke test with the ui helper. Each run spends the Claude subscription's usage
+ # limits, so it runs only when asked for. The app-sim-claude environment's branch rule decides which branches
+ # may use the token.
+ claude:
+ name: Claude tests the desktop app
+ needs: [plan, core, backend]
+ if: ${{ !cancelled() && !failure() && needs.plan.outputs.run-claude == 'true' }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 60
+ environment:
+ name: app-sim-claude
+ deployment: false
+ concurrency:
+ group: desktop-linux-claude-${{ github.ref }}
+ cancel-in-progress: true
+ env:
+ # the first release with Haiku 5.5's token counts right (2.1.293 added the model, 2.1.296 fixed its counts)
+ CLAUDE_CODE_VERSION: 2.1.296
+ CLAUDE_MODEL: claude-haiku-5-5
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+ with:
+ submodules: recursive
+ persist-credentials: false
+
+ - name: Install tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
+ dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
+
+ - name: Download core library
+ uses: actions/download-artifact@v7
+ with:
+ name: desktop-core-linux-x86_64
+ path: ~/core
+
+ - name: Install core library
+ run: scripts/ci/desktop-linux/install-core.sh ~/core/${{ env.CORE_PKG }}
+
+ - name: Download backend
+ if: needs.plan.outputs.run-backend == 'true'
+ uses: actions/download-artifact@v7
+ with:
+ name: ${{ needs.plan.outputs.backend }}
+ path: ~/backend
+ run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
+ github-token: ${{ github.token }}
+
+ - name: Start test backend
+ if: needs.plan.outputs.run-backend == 'true'
+ timeout-minutes: 30
+ run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
+
+ - name: Setup Java
+ uses: actions/setup-java@v5
+ with:
+ distribution: corretto
+ java-version: ${{ env.JAVA_VER }}
+ cache: gradle
+
+ - name: Build app
+ working-directory: apps/multiplatform
+ run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :desktop:createDistributable
+
+ # ui goes on every shell's PATH, where Claude's Bash tool finds it whatever environment it builds
+ - name: Install Claude Code and the ui command
+ run: |
+ npm install --prefix "$RUNNER_TEMP/claude-code" "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION"
+ echo "$RUNNER_TEMP/claude-code/node_modules/.bin" >> "$GITHUB_PATH"
+ printf '#!/bin/sh\nexec %s/scripts/ci/desktop-linux/ui "$@"\n' "$PWD" | sudo tee /usr/local/bin/ui
+ sudo chmod +x /usr/local/bin/ui
+
+ - name: Start the app
+ run: scripts/ci/desktop-linux/smoke-test.sh apps/multiplatform/release/main/app/simplex "$RUNNER_TEMP/claude-test/screens" start
+
+ # Claude's Bash is limited to single ui commands by the guard hook in claude-test.sh. Claude Code's credential
+ # scrub stays off: on Linux it forces its whole Bash sandbox (bubblewrap, socat), which ui isn't set up for.
+ - name: Run Claude
+ id: claude
+ env:
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1
+ run: scripts/ci/desktop-linux/claude-test.sh "$RUNNER_TEMP/claude-test" "$RUNNER_TEMP/claude-transcript.jsonl"
+
+ - name: Report Claude usage
+ if: ${{ !cancelled() && steps.claude.outcome != 'skipped' }}
+ run: scripts/ci/desktop-linux/claude-usage.py "$RUNNER_TEMP/claude-transcript.jsonl" "$RUNNER_TEMP/claude-usage.json"
+
+ - name: Stop test backend
+ if: ${{ !cancelled() && needs.plan.outputs.run-backend == 'true' }}
+ run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
+
+ - name: Upload Claude test results
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v6
+ with:
+ name: desktop-claude-test
+ path: |
+ ${{ runner.temp }}/claude-test
+ ${{ runner.temp }}/claude-transcript.jsonl
+ ${{ runner.temp }}/claude-transcript.jsonl.stderr
+ ${{ runner.temp }}/claude-transcript.jsonl.guard.log
+ ${{ runner.temp }}/claude-usage.json
+ retention-days: 7
+ if-no-files-found: warn
+
+ - name: Upload backend logs
+ if: ${{ !cancelled() && needs.plan.outputs.run-backend == 'true' }}
+ uses: actions/upload-artifact@v6
+ with:
+ name: desktop-claude-backend-logs
+ path: ${{ runner.temp }}/backend/*.log
+ retention-days: 3
+ if-no-files-found: warn
+
+ # the latest release's onboarding, to tell a branch's own failure from the runner's
+ release:
+ name: released app under Xvfb
+ needs: plan
+ if: needs.plan.outputs.run-backend == 'true'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+ filter: tree:0
+
+ - name: Download released app
+ run: scripts/ci/desktop-linux/release-app.sh "$RUNNER_TEMP/release"
+
+ - name: Install UI tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr
+ dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr
+
+ - name: Run smoke test
+ run: scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/release/app/opt/simplex" "$RUNNER_TEMP/smoke-test" onboarding
+
+ - name: Upload smoke test results
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v6
+ with:
+ name: desktop-release-smoke-test
+ path: ${{ runner.temp }}/smoke-test
+ retention-days: 3
+ if-no-files-found: warn
diff --git a/.github/workflows/ios-sim.yml b/.github/workflows/ios-sim.yml
new file mode 100644
index 0000000000..5d44162369
--- /dev/null
+++ b/.github/workflows/ios-sim.yml
@@ -0,0 +1,348 @@
+name: ios-sim
+
+on:
+ push:
+ branches:
+ - '**'
+ # a squash merge's message lists the merged commits' subjects, with their tags
+ - '!master'
+ - '!stable'
+ workflow_dispatch:
+ inputs:
+ claude:
+ description: Also run the Claude session
+ type: boolean
+ default: false
+ backend:
+ description: Start the test backend
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+ actions: read
+
+concurrency:
+ # a push that asks for no run must not cancel one that does
+ group: ios-sim-${{ github.ref }}-${{ (github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:ios]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]')) && 'requested' || github.run_id }}
+ cancel-in-progress: true
+
+env:
+ GHC_VER: 9.6.3
+ CABAL_VER: 3.10.2.0
+ CORE_PKG: pkg-ios-aarch64-swift-json.zip
+ BACKEND_PKG: ios-sim-backend.zip
+ HOMEBREW_NO_AUTO_UPDATE: 1
+ HOMEBREW_NO_INSTALL_CLEANUP: 1
+ # newer images add Command Line Tools whose macOS SDK the default Xcode's linker cannot read
+ DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
+
+jobs:
+ plan:
+ name: find earlier builds
+ if: >-
+ github.event_name == 'workflow_dispatch' ||
+ contains(github.event.head_commit.message, '[app-ci]') ||
+ contains(github.event.head_commit.message, '[app-ci:ios]') ||
+ contains(github.event.head_commit.message, '[claude-test]') ||
+ contains(github.event.head_commit.message, '[claude-test:ios]')
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ outputs:
+ core: ${{ steps.names.outputs.core }}
+ backend: ${{ steps.names.outputs.backend }}
+ core-run: ${{ steps.core.outputs.run-id }}
+ backend-run: ${{ steps.backend.outputs.run-id }}
+ claude: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]') }}
+ start-backend: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ # artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded
+ - name: Artifact names
+ id: names
+ run: |
+ echo "core=ios-sim-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-core.sh') }}" >> "$GITHUB_OUTPUT"
+ echo "backend=ios-sim-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-backend.sh') }}" >> "$GITHUB_OUTPUT"
+
+ - name: Find core library
+ id: core
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.core }}"
+
+ - name: Find backend
+ id: backend
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.backend }}"
+
+ core:
+ name: core library for the iOS simulator
+ needs: plan
+ if: needs.plan.outputs.core-run == ''
+ runs-on: macos-latest
+ timeout-minutes: 240
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Show runner
+ run: |
+ sw_vers
+ sysctl -n hw.ncpu hw.memsize
+ df -h /
+ xcodebuild -version
+ xcrun --show-sdk-path
+ ls /Library/Developer/CommandLineTools/SDKs || true
+
+ - name: Use the macOS 26 SDK
+ run: scripts/ci/ios-sim/use-macos26-sdk.sh
+
+ # without ~/.cabal, cabal 3.10 keeps its store in XDG directories, not where setup-haskell reports it
+ - name: Use ~/.cabal for cabal
+ run: echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV"
+
+ - name: Setup Haskell
+ id: haskell
+ uses: simplex-chat/setup-haskell-action@v2
+ with:
+ ghc-version: ${{ env.GHC_VER }}
+ cabal-version: ${{ env.CABAL_VER }}
+
+ - name: Restore cabal store
+ id: cabal-cache
+ uses: actions/cache/restore@v4
+ with:
+ path: |
+ ${{ steps.haskell.outputs.cabal-store }}
+ dist-newstyle
+ key: ios-sim-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }}
+ restore-keys: |
+ ios-sim-core-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-
+ ios-sim-core-store-ghc${{ env.GHC_VER }}-
+
+ - name: Install libraries
+ run: brew install openssl@3 libffi gmp
+
+ - name: Build core library
+ id: build
+ run: scripts/ci/ios-sim/build-core.sh ~/Downloads/${{ env.CORE_PKG }}
+
+ - name: Save cabal store
+ if: ${{ !cancelled() }}
+ uses: actions/cache/save@v4
+ with:
+ path: |
+ ${{ steps.haskell.outputs.cabal-store }}
+ dist-newstyle
+ key: ${{ steps.cabal-cache.outputs.cache-primary-key }}
+
+ - name: Patch core library for the simulator
+ run: scripts/ci/ios-sim/prepare-libs.sh
+
+ - name: Upload core library
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ needs.plan.outputs.core }}
+ path: ~/Downloads/${{ env.CORE_PKG }}
+ compression-level: 0
+ retention-days: 30
+ if-no-files-found: error
+
+ backend:
+ name: test backend for the app
+ needs: plan
+ if: needs.plan.outputs.start-backend == 'true' && needs.plan.outputs.backend-run == ''
+ runs-on: macos-latest
+ timeout-minutes: 240
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Show runner
+ run: |
+ sw_vers
+ xcodebuild -version
+ xcrun --show-sdk-path
+ ls /Library/Developer/CommandLineTools/SDKs || true
+
+ - name: Use the macOS 26 SDK
+ run: scripts/ci/ios-sim/use-macos26-sdk.sh
+
+ - name: Use ~/.cabal for cabal
+ run: echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV"
+
+ - name: Setup Haskell
+ id: haskell
+ uses: simplex-chat/setup-haskell-action@v2
+ with:
+ ghc-version: ${{ env.GHC_VER }}
+ cabal-version: ${{ env.CABAL_VER }}
+
+ - name: Restore cabal store
+ id: cabal-cache
+ uses: actions/cache/restore@v4
+ with:
+ path: |
+ ${{ steps.haskell.outputs.cabal-store }}
+ dist-newstyle
+ key: ios-sim-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-${{ github.run_id }}
+ restore-keys: |
+ ios-sim-backend-store-ghc${{ env.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}-
+ ios-sim-backend-store-ghc${{ env.GHC_VER }}-
+
+ - name: Build backend
+ run: scripts/ci/ios-sim/build-backend.sh ~/Downloads/${{ env.BACKEND_PKG }}
+
+ - name: Save cabal store
+ if: ${{ !cancelled() }}
+ uses: actions/cache/save@v4
+ with:
+ path: |
+ ${{ steps.haskell.outputs.cabal-store }}
+ dist-newstyle
+ key: ${{ steps.cabal-cache.outputs.cache-primary-key }}
+
+ - name: Upload backend
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ needs.plan.outputs.backend }}
+ path: ~/Downloads/${{ env.BACKEND_PKG }}
+ compression-level: 0
+ retention-days: 30
+ if-no-files-found: error
+
+ app:
+ name: app in the iOS simulator
+ needs: [plan, core, backend]
+ # core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it
+ if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }}
+ runs-on: macos-latest
+ environment:
+ name: ${{ needs.plan.outputs.claude == 'true' && 'app-sim-claude' || '' }}
+ deployment: false
+ timeout-minutes: 330
+ env:
+ CLAUDE_RUN: ${{ needs.plan.outputs.claude }}
+ START_BACKEND: ${{ needs.plan.outputs.start-backend }}
+ steps:
+ # the job later holds the Claude token, so the checkout's GitHub token is not left in .git/config
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Show runner
+ run: |
+ sw_vers
+ df -h /
+ xcodebuild -version
+ xcrun --show-sdk-path
+ ls /Library/Developer/CommandLineTools/SDKs || true
+ xcrun simctl list runtimes
+
+ - name: Use the macOS 26 SDK
+ run: scripts/ci/ios-sim/use-macos26-sdk.sh
+
+ - name: Download core library
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.plan.outputs.core }}
+ path: ~/Downloads
+ run-id: ${{ needs.plan.outputs.core-run || github.run_id }}
+ github-token: ${{ github.token }}
+
+ - name: Prepare libraries
+ run: |
+ scripts/ci/ios-sim/prepare-libs.sh
+ scripts/ios/update-pbxproj.sh
+
+ - name: Download backend
+ if: env.START_BACKEND == 'true'
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.plan.outputs.backend }}
+ path: ~/Downloads
+ run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
+ github-token: ${{ github.token }}
+
+ - name: Start test backend
+ if: env.START_BACKEND == 'true'
+ timeout-minutes: 30
+ run: scripts/ci/ios-sim/backend.sh start ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
+
+ - name: Build app
+ run: |
+ xcodebuild build -quiet \
+ -project apps/ios/SimpleX.xcodeproj \
+ -scheme "SimpleX (iOS)" \
+ -configuration Debug \
+ -destination 'generic/platform=iOS Simulator' \
+ -derivedDataPath "$RUNNER_TEMP/DerivedData" \
+ -onlyUsePackageVersionsFromResolvedFile \
+ -skipPackagePluginValidation \
+ -skipMacroValidation \
+ ARCHS=arm64 \
+ CODE_SIGN_STYLE=Manual \
+ CODE_SIGN_IDENTITY=- \
+ DEVELOPMENT_TEAM= \
+ PROVISIONING_PROFILE_SPECIFIER=
+
+ - name: Install AXe
+ run: scripts/ci/ios-sim/install-axe.sh
+
+ - name: Run smoke test
+ run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator/SimpleX.app" "$RUNNER_TEMP/smoke-test"
+
+ - name: Install Claude Code
+ if: env.CLAUDE_RUN == 'true'
+ run: |
+ npm install -g @anthropic-ai/claude-code@2.1.300
+ claude --version
+
+ - name: Run the device test plan with Claude
+ if: env.CLAUDE_RUN == 'true'
+ timeout-minutes: 240
+ env:
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ BACKEND_DIR: ${{ env.START_BACKEND == 'true' && format('{0}/backend', runner.temp) || '' }}
+ run: scripts/ci/ios-sim/claude-test.sh "$RUNNER_TEMP/claude-test"
+
+ - name: Report Claude usage
+ if: ${{ always() && env.CLAUDE_RUN == 'true' }}
+ run: scripts/ci/ios-sim/claude-usage.sh "$RUNNER_TEMP/claude-test"
+
+ - name: Stop test backend
+ if: ${{ !cancelled() && env.START_BACKEND == 'true' }}
+ run: scripts/ci/ios-sim/backend.sh stop "$RUNNER_TEMP/backend"
+
+ - name: Upload smoke test results
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: ios-sim-smoke-test
+ path: ${{ runner.temp }}/smoke-test
+ retention-days: 3
+ if-no-files-found: warn
+
+ - name: Upload backend logs
+ if: ${{ !cancelled() && env.START_BACKEND == 'true' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: ios-sim-backend-logs
+ path: |
+ ${{ runner.temp }}/backend/*.log
+ ${{ runner.temp }}/backend/seed.md
+ retention-days: 3
+ if-no-files-found: warn
+
+ - name: Upload Claude test results
+ if: ${{ always() && env.CLAUDE_RUN == 'true' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: ios-sim-claude-test
+ path: ${{ runner.temp }}/claude-test
+ retention-days: 14
+ if-no-files-found: warn
diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml
new file mode 100644
index 0000000000..01c2c1be33
--- /dev/null
+++ b/.github/workflows/packages.yml
@@ -0,0 +1,382 @@
+name: packages
+
+# Releases simplex-chat (npm, PyPI) and @simplex-chat/types (npm) from master and stable:
+# 1. A missing Node.js add-on release (nodejs-addon-) is built and uploaded to simplex-chat-libs.
+# 2. Versions that are not published yet are published, and recorded in releases.toml of simplex-chat-libs.
+# npm prerelease versions get dist-tag beta, others latest.
+# 3. The release PR packages-release/ pins new library and add-on releases with their SHA-256
+# and bumps the versions of changed packages. Merging it publishes them (step 2).
+# Package tests run on every platform with the pinned libsimplex. build.yml runs them with the libsimplex it builds:
+# for pull requests that change the chat core (linux-x86_64), and for a new library release before its release PR.
+
+# Publishing uses trusted publishing (OIDC) for this workflow on npm and PyPI,
+# or the NPM_TOKEN and PYPI_API_TOKEN secrets when they are set.
+# Test repositories: variables NPM_SCOPE, PYPI_PROJECT and PYPI_URL / PYPI_UPLOAD_URL (TestPyPI)
+# publish under other names.
+
+# Secrets:
+# -------
+# NODEJS_REPO_TOKEN
+# Only select repositories: simplex-chat-libs
+# Permissions:
+# * Contents (Read and Write)
+# RELEASE_PR_TOKEN
+# Only select repositories: simplex-chat
+# Permissions:
+# * Contents (Read and Write)
+# * Pull requests (Read and Write)
+# A PR opened with GITHUB_TOKEN does not run the checks of other workflows.
+
+on:
+ workflow_dispatch:
+ inputs:
+ nodejs_version:
+ description: "simplex-chat npm version, bumped when needed by default"
+ required: false
+ types_version:
+ description: "@simplex-chat/types version, bumped when needed by default"
+ required: false
+ python_version:
+ description: "simplex-chat PyPI version, bumped when needed by default"
+ required: false
+ # build.yml: a pull request that changes the chat core, or a new library release
+ workflow_call:
+ inputs:
+ libs_artifact:
+ description: "test with simplex-chat-libs-linux-x86_64 built by the calling run"
+ type: boolean
+ default: false
+ libs_release:
+ description: "test with this simplex-chat-libs release"
+ type: string
+ default: ""
+ push:
+ branches:
+ - master
+ - stable
+ paths:
+ - "packages/simplex-chat-nodejs/**"
+ - "packages/simplex-chat-client/types/typescript/**"
+ - "packages/simplex-chat-python/**"
+ - ".github/workflows/packages.yml"
+ pull_request:
+ paths:
+ - "packages/simplex-chat-nodejs/**"
+ - "packages/simplex-chat-client/types/typescript/**"
+ - "packages/simplex-chat-python/**"
+ - ".github/workflows/packages.yml"
+
+concurrency:
+ # build.yml and this workflow both run for a pull request that changes the core and the packages
+ group: ${{ github.workflow }}-packages-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+ contents: read
+
+env:
+ NPM_SCOPE: ${{ vars.NPM_SCOPE }}
+ PYPI_PROJECT: ${{ vars.PYPI_PROJECT }}
+ PYPI_URL: ${{ vars.PYPI_URL }}
+
+jobs:
+ addon:
+ runs-on: ubuntu-latest
+ outputs:
+ tag: ${{ steps.tag.outputs.tag }}
+ release_branch: ${{ steps.branch.outputs.release_branch }}
+ missing: ${{ steps.release.outputs.missing }}
+ platforms: ${{ steps.plan.outputs.platforms }}
+ test: ${{ steps.plan.outputs.test }}
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v6
+
+ - name: Add-on release tag
+ id: tag
+ run: echo "tag=$(python3 scripts/ci/packages_release.py addon-tag)" >> "$GITHUB_OUTPUT"
+
+ - name: Release branch
+ id: branch
+ if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable')
+ run: echo "release_branch=true" >> "$GITHUB_OUTPUT"
+
+ - name: Check the add-on release
+ id: release
+ if: steps.branch.outputs.release_branch
+ env:
+ GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }}
+ LIBS_REPOSITORY: ${{ github.repository }}-libs
+ TAG: ${{ steps.tag.outputs.tag }}
+ run: |
+ if ! gh release view "$TAG" --repo "$LIBS_REPOSITORY" > /dev/null 2>&1; then
+ echo "missing=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Build plan
+ id: plan
+ env:
+ EVENT: ${{ github.event_name }}
+ BASE: ${{ github.event.pull_request.base.sha }}
+ RELEASE_MISSING: ${{ steps.release.outputs.missing }}
+ LIBS_ARTIFACT: ${{ inputs.libs_artifact }}
+ LIBS_RELEASE: ${{ inputs.libs_release }}
+ run: |
+ if [ -n "$BASE" ]; then git fetch -q --depth=1 origin "$BASE"; fi
+ python3 scripts/ci/packages_release.py build-plan --event "$EVENT" --base "$BASE" \
+ --release-missing "$RELEASE_MISSING" --libs-artifact "$LIBS_ARTIFACT" --libs-release "$LIBS_RELEASE" >> "$GITHUB_OUTPUT"
+
+ build:
+ name: "build ${{ matrix.platform }}"
+ needs: addon
+ if: needs.addon.outputs.platforms != '[]'
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ include: ${{ fromJSON(needs.addon.outputs.platforms) }}
+ defaults:
+ run:
+ shell: bash
+ working-directory: packages/simplex-chat-nodejs
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v6
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: 22
+
+ - name: Setup Python
+ uses: actions/setup-python@v7
+ with:
+ python-version: "3.12"
+
+ # The local types: the dependency can be bumped to a version that is not published yet.
+ - name: Build simplex-chat (npm) and add-on
+ run: |
+ (cd ../simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc)
+ npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript
+ npx --yes node-gyp rebuild
+ npx tsc && cp src/simplex.* dist
+ cp build/Release/simplex.node build/simplex-chat-nodejs-${{ matrix.platform }}.node
+
+ - name: Download libsimplex of this run
+ if: needs.addon.outputs.test && inputs.libs_artifact
+ uses: actions/download-artifact@v8
+ with:
+ name: simplex-chat-libs-${{ matrix.platform }}
+ path: ${{ runner.temp }}/libsimplex
+
+ - name: Download libsimplex release
+ if: needs.addon.outputs.test && inputs.libs_release
+ env:
+ URL: https://github.com/${{ github.repository }}-libs/releases/download/${{ inputs.libs_release }}/simplex-chat-libs-${{ matrix.platform }}.zip
+ run: curl -fsSL --create-dirs -o "$RUNNER_TEMP/libsimplex/simplex-chat-libs-${{ matrix.platform }}.zip" "$URL"
+
+ - name: Unpack libsimplex
+ if: needs.addon.outputs.test && (inputs.libs_artifact || inputs.libs_release)
+ run: python -m zipfile -e "$RUNNER_TEMP/libsimplex/simplex-chat-libs-${{ matrix.platform }}.zip" "$RUNNER_TEMP/libsimplex"
+
+ - name: Test simplex-chat (npm)
+ if: needs.addon.outputs.test
+ env:
+ SIMPLEX_LIBS_DIR: ${{ (inputs.libs_artifact || inputs.libs_release) && format('{0}/libsimplex/libs', runner.temp) || '' }}
+ # A failed test can leave chat controllers running, and jest then waits for them.
+ # Tests on the public preset servers fail in CI when a server is slow to connect.
+ run: npx jest --forceExit --testNamePattern '^(?!.*use preset servers)'
+
+ - name: Test simplex-chat (PyPI)
+ if: needs.addon.outputs.test
+ working-directory: packages/simplex-chat-python
+ run: |
+ python -m pip install -e ".[test]"
+ python -m pytest -q
+
+ - name: Upload add-on artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: simplex-nodejs-addon-${{ matrix.platform }}
+ path: packages/simplex-chat-nodejs/build/simplex-chat-nodejs-${{ matrix.platform }}.node
+ if-no-files-found: error
+
+ publish:
+ needs: addon
+ # Trusted publishing accepts this workflow only when it is not called by another one (build.yml).
+ if: needs.addon.outputs.release_branch && contains(github.workflow_ref, '/.github/workflows/packages.yml@')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ # set by a publish step after it succeeds, so a failed publish still reports the earlier ones
+ outputs:
+ types: ${{ steps.types.outputs.version }}
+ nodejs: ${{ steps.nodejs.outputs.version }}
+ python: ${{ steps.python.outputs.version }}
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v6
+
+ - name: Versions to publish
+ id: plan
+ env:
+ GH_TOKEN: ${{ github.token }}
+ LIBS_REPOSITORY: ${{ github.repository }}-libs
+ run: python3 scripts/ci/packages_release.py publish-plan >> "$GITHUB_OUTPUT"
+
+ - name: Setup Node.js
+ if: steps.plan.outputs.types_version || steps.plan.outputs.nodejs_version
+ uses: actions/setup-node@v7
+ with:
+ node-version: 24
+ registry-url: https://registry.npmjs.org
+
+ - name: Setup Python
+ if: steps.plan.outputs.python_version
+ uses: actions/setup-python@v7
+ with:
+ python-version: "3.12"
+
+ # Before the test names: the build installs @simplex-chat/types from this repository.
+ - name: Build and test simplex-chat (npm)
+ if: steps.plan.outputs.nodejs_version
+ working-directory: packages/simplex-chat-nodejs
+ run: |
+ (cd ../simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc)
+ npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript
+ npx tsc && cp src/simplex.* dist
+ npx jest tests/libs.test.ts tests/api.unit.test.ts
+
+ - name: Use test package names
+ if: vars.NPM_SCOPE || vars.PYPI_PROJECT
+ run: python3 scripts/ci/packages_release.py test-names
+
+ - name: Publish @simplex-chat/types
+ id: types
+ if: steps.plan.outputs.types_version
+ working-directory: packages/simplex-chat-client/types/typescript
+ env:
+ VERSION: ${{ steps.plan.outputs.types_version }}
+ DIST_TAG: ${{ steps.plan.outputs.types_dist_tag }}
+ run: |
+ npm install --no-audit --no-fund
+ npm publish --provenance --access public --tag "$DIST_TAG"
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
+ - name: Publish simplex-chat (npm)
+ id: nodejs
+ if: steps.plan.outputs.nodejs_version
+ working-directory: packages/simplex-chat-nodejs
+ env:
+ VERSION: ${{ steps.plan.outputs.nodejs_version }}
+ DIST_TAG: ${{ steps.plan.outputs.nodejs_dist_tag }}
+ run: |
+ npm publish --provenance --access public --tag "$DIST_TAG"
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
+ - name: Build and test simplex-chat (PyPI)
+ if: steps.plan.outputs.python_version
+ working-directory: packages/simplex-chat-python
+ run: |
+ python -m pip install build -e ".[test]"
+ python -m pytest -q
+ python -m build
+
+ - name: Publish simplex-chat (PyPI)
+ if: steps.plan.outputs.python_version
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: packages/simplex-chat-python/dist
+ repository-url: ${{ vars.PYPI_UPLOAD_URL || 'https://upload.pypi.org/legacy/' }}
+ password: ${{ secrets.PYPI_API_TOKEN }}
+
+ - name: Report the published PyPI version
+ id: python
+ if: steps.plan.outputs.python_version
+ env:
+ VERSION: ${{ steps.plan.outputs.python_version }}
+ run: echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
+ # The only job here that writes to simplex-chat-libs; publish runs npm and pip installs without its token.
+ record:
+ needs: publish
+ if: ${{ !cancelled() && (needs.publish.outputs.types || needs.publish.outputs.nodejs || needs.publish.outputs.python) }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v6
+
+ - name: Record published versions
+ env:
+ GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }}
+ LIBS_REPOSITORY: ${{ github.repository }}-libs
+ TYPES: ${{ needs.publish.outputs.types }}
+ NODEJS: ${{ needs.publish.outputs.nodejs }}
+ PYTHON: ${{ needs.publish.outputs.python }}
+ run: |
+ python3 scripts/ci/packages_release.py record --commit "$GITHUB_SHA" \
+ --types "$TYPES" --nodejs "$NODEJS" --python "$PYTHON"
+
+ release:
+ needs: [addon, build]
+ # A merged release PR changes only versions and pins.
+ if: >-
+ !cancelled() && needs.addon.outputs.release_branch && needs.build.result != 'failure'
+ && !startsWith(github.event.head_commit.message, 'packages: release')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ # Outside the checkout: the release PR commits every change in it.
+ - name: Download add-on artifacts
+ if: needs.addon.outputs.missing
+ uses: actions/download-artifact@v8
+ with:
+ pattern: simplex-nodejs-addon-*
+ merge-multiple: true
+ path: ${{ runner.temp }}/addons
+
+ - name: Create add-on release
+ if: needs.addon.outputs.missing
+ uses: softprops/action-gh-release@v3
+ with:
+ repository: ${{ github.repository }}-libs
+ tag_name: ${{ needs.addon.outputs.tag }}
+ files: ${{ runner.temp }}/addons/*.node
+ token: ${{ secrets.NODEJS_REPO_TOKEN }}
+
+ - name: Update pins and versions
+ id: prepare
+ env:
+ GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }}
+ LIBS_REPOSITORY: ${{ github.repository }}-libs
+ NODEJS_VERSION: ${{ inputs.nodejs_version }}
+ TYPES_VERSION: ${{ inputs.types_version }}
+ PYTHON_VERSION: ${{ inputs.python_version }}
+ run: |
+ python3 scripts/ci/packages_release.py prepare \
+ --nodejs-version "$NODEJS_VERSION" \
+ --types-version "$TYPES_VERSION" \
+ --python-version "$PYTHON_VERSION" \
+ --summary "$RUNNER_TEMP/summary.md"
+ echo "title=$(head -n 1 "$RUNNER_TEMP/summary.md")" >> "$GITHUB_OUTPUT"
+
+ - name: Create or update release PR
+ uses: peter-evans/create-pull-request@v8
+ with:
+ token: ${{ secrets.RELEASE_PR_TOKEN }}
+ branch: packages-release/${{ github.ref_name }}
+ base: ${{ github.ref_name }}
+ title: ${{ steps.prepare.outputs.title }}
+ commit-message: ${{ steps.prepare.outputs.title }}
+ body-path: ${{ runner.temp }}/summary.md
+ author: SimpleX Chat
+ committer: SimpleX Chat
+ delete-branch: true
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index 8f9496976c..ce2376989f 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -492,10 +492,27 @@ final class ChatModel: ObservableObject {
// another in its place; only the badge alert shows regardless. Set while rendering, so not published.
var chatListBanner: ChatListBanner?
+ var fileToOpen: (fileId: Int64, chatId: String?, open: (CIFile) -> Void)?
+
func bannerSlotFree(for banner: ChatListBanner) -> Bool {
chatListBanner == nil || chatListBanner == banner
}
+ func openWhenReceived(_ file: CIFile, open: @escaping (CIFile) -> Void) {
+ fileToOpen = (fileId: file.fileId, chatId: chatId, open: open)
+ }
+
+ func receivedFileToOpen(_ file: CIFile) -> Bool {
+ if let f = fileToOpen { f.fileId == file.fileId && f.chatId == chatId } else { false }
+ }
+
+ func openReceivedFile(_ file: CIFile) {
+ if let f = fileToOpen, receivedFileToOpen(file) {
+ fileToOpen = nil
+ f.open(file)
+ }
+ }
+
static let shared = ChatModel()
let im = ItemsModel.shared
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index 2c82349bc8..58495a63ca 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -2899,6 +2899,9 @@ func processReceivedMsg(_ res: ChatEvent) async {
await chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileComplete(user, aChatItem):
await chatItemSimpleUpdate(user, aChatItem)
+ if active(user), let file = aChatItem.chatItem.file, m.receivedFileToOpen(file) {
+ await MainActor.run { m.openReceivedFile(file) }
+ }
case let .rcvFileSndCancelled(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
index 8bb76ca155..5c65a310b5 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
@@ -26,7 +26,7 @@ struct CIFileView: View {
var body: some View {
if smallViewSize != nil {
fileIndicator()
- .simultaneousGesture(TapGesture().onEnded(fileAction))
+ .simultaneousGesture(TapGesture().onEnded { fileAction(file) })
} else {
// reserve exact space for the overlaid meta (timestamp + all icons), rendered transparently - matches MsgContentView
let encrypted: Bool? = if let fileSource = file?.fileSource { fileSource.cryptoArgs != nil } else { nil }
@@ -56,7 +56,7 @@ struct CIFileView: View {
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
- .simultaneousGesture(TapGesture().onEnded(fileAction))
+ .simultaneousGesture(TapGesture().onEnded { fileAction(file) })
.disabled(!itemInteractive)
}
}
@@ -85,7 +85,7 @@ struct CIFileView: View {
return false
}
- private func fileAction() {
+ private func fileAction(_ file: CIFile?) {
logger.debug("CIFileView fileAction")
if let file = file {
switch (file.fileStatus) {
@@ -93,6 +93,7 @@ struct CIFileView: View {
if let prohibited = file.fileProhibited {
showProhibitedFileAlert(file, prohibited)
} else {
+ m.openWhenReceived(file) { fileAction($0) }
Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task")
if let user = m.currentUser {
@@ -117,7 +118,7 @@ struct CIFileView: View {
case .rcvComplete:
logger.debug("CIFileView fileAction - in .rcvComplete")
if let fileSource = getLoadedFileSource(file) {
- saveCryptoFile(fileSource)
+ openFile(file, fileSource)
}
case let .rcvError(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvError")
@@ -128,12 +129,12 @@ struct CIFileView: View {
case .sndStored:
logger.debug("CIFileView fileAction - in .sndStored")
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
- saveCryptoFile(fileSource)
+ openFile(file, fileSource)
}
case .sndComplete:
logger.debug("CIFileView fileAction - in .sndComplete")
if let fileSource = getLoadedFileSource(file) {
- saveCryptoFile(fileSource)
+ openFile(file, fileSource)
}
case let .sndError(sndFileError):
logger.debug("CIFileView fileAction - in .sndError")
@@ -146,6 +147,14 @@ struct CIFileView: View {
}
}
+ private func openFile(_ file: CIFile, _ fileSource: CryptoFile) {
+ if isMarkdownFile(file) {
+ showMarkdownFile(file)
+ } else {
+ saveCryptoFile(fileSource)
+ }
+ }
+
@ViewBuilder private func fileIndicator() -> some View {
if let file = file {
switch file.fileStatus {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
index fb6ccb1ea3..469fc5a3fc 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
@@ -54,6 +54,7 @@ struct CIImageView: View {
if let prohibited = file.fileProhibited {
showProhibitedFileAlert(file, prohibited)
} else {
+ m.openWhenReceived(file) { _ in showFullScreenImage = true }
Task {
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
index 4eb187bcac..42259361b7 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
@@ -52,7 +52,7 @@ struct CILinkView: View {
func openBrowserAlert(uri: String) {
let (url, err) = sanitizeUri(uri)
if let url {
- let uriStr = url.uri.absoluteString
+ let uriStr = displayedUri(url.uri.absoluteString)
showAlert(
NSLocalizedString("Open link?", comment: "alert title"),
message: uriStr.count > 160 ? "\(uriStr.prefix(160))…" : uriStr,
@@ -90,9 +90,9 @@ func openBrowserAlert(uri: String) {
func showInvalidLinkAlert(_ uri: String, error: String? = nil) {
let message = if let error, !error.isEmpty {
- error + "\n" + uri
+ error + "\n" + displayedUri(uri)
} else {
- uri
+ displayedUri(uri)
}
showAlert(
NSLocalizedString("Invalid link", comment: "alert title"),
@@ -101,6 +101,25 @@ func showInvalidLinkAlert(_ uri: String, error: String? = nil) {
)
}
+func unicodeMarker(_ s: Unicode.Scalar) -> String {
+ String(format: "[U+%04X]", s.value)
+}
+
+func displayedUri(_ uri: String) -> String {
+ let authorityStart = uri.range(of: "://")?.upperBound ?? uri.startIndex
+ let authority = authorityStart ..< (uri[authorityStart...].firstIndex(where: { "/?#".contains($0) }) ?? uri.endIndex)
+ var shown = ""
+ for i in uri.unicodeScalars.indices {
+ let s = uri.unicodeScalars[i]
+ if s.properties.generalCategory == .format || (!s.isASCII && authority.contains(i)) {
+ shown += unicodeMarker(s)
+ } else {
+ shown.unicodeScalars.append(s)
+ }
+ }
+ return shown
+}
+
func sanitizeUri(_ s: String) -> (url: (uri: URL, sanitizedUri: URL?)?, error: String?) {
let parsed = parseSanitizeUri(s, safe: false)
return if let uri = URL(string: s), let uriInfo = parsed?.uriInfo {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
index 0df17dfe3d..339d119362 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
@@ -120,6 +120,25 @@ struct CIVideoView: View {
}
}
+ private func playReceived(_ file: CIFile) {
+ setupPlayer(file)
+ if urlDecrypted == nil {
+ decrypt(file: file, completed: playVideo)
+ } else {
+ playVideo()
+ }
+ }
+
+ private func playVideo() {
+ if smallView {
+ showFullScreenPlayer = urlDecrypted != nil
+ } else if let urlDecrypted {
+ m.stopPreviousRecPlay = urlDecrypted
+ videoPlaying = true
+ player?.play()
+ }
+ }
+
private func setupPlayer(_ file: CIFile?) {
let newUrl = getLoadedVideo(file)
if newUrl == url {
@@ -424,6 +443,7 @@ struct CIVideoView: View {
if let prohibited = file.fileProhibited {
showProhibitedFileAlert(file, prohibited)
} else {
+ m.openWhenReceived(file, open: playReceived)
Task {
if let user = m.currentUser {
await receiveFile(user, file.fileId, false, false)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
index 64fa2a4dd3..6c89a5f788 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
@@ -303,6 +303,11 @@ struct VoiceMessagePlayer: View {
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
playPauseIcon(icon, theme.colors.primary)
.simultaneousGesture(TapGesture().onEnded {
+ chatModel.openWhenReceived(recordingFile) { file in
+ if let recordingSource = getLoadedFileSource(file) {
+ startPlayback(recordingSource)
+ }
+ }
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift
new file mode 100644
index 0000000000..aa7e827d6b
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift
@@ -0,0 +1,215 @@
+import SwiftUI
+import SimpleXChat
+import Ink
+
+private let MAX_MARKDOWN_VIEW_SIZE: Int64 = 1_048_576
+private let MAX_MARKDOWN_LINE_LENGTH = 15_000
+private let MAX_MARKDOWN_NESTING = 16
+private let MAX_MARKDOWN_LINK_NESTING = 4
+private let MAX_MARKDOWN_TABLE_ROWS = 200
+private let MAX_MARKDOWN_IMAGE_DIMENSION: CGFloat = 4320
+private let MAX_MARKDOWN_IMAGE_PIXELS: CGFloat = 2160 * 4320
+
+func isMarkdownFile(_ file: CIFile) -> Bool {
+ let ext = (file.fileName as NSString).pathExtension.lowercased()
+ return file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && (ext == "md" || ext == "markdown")
+}
+
+func showMarkdownFile(_ file: CIFile) {
+ guard let fileSource = getLoadedFileSource(file) else { return }
+ Task {
+ do {
+ let data = try getFileData(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs)
+ let text = decodedText(data)
+ let lines = text.components(separatedBy: "\n")
+ let html = withinMarkdownLimits(lines)
+ ? MarkdownParser(modifiers: [Modifier(target: .html) { escapedHTML($0.markdown) }, imagesWithinBudget()]).html(from: text)
+ : nil
+ let shownLines = lines.flatMap(chunkedLine)
+ let hasBidiControls = text.unicodeScalars.contains(where: isBidiControl)
+ await MainActor.run {
+ showAppSheet {
+ MarkdownFileView(file: file, fileSource: fileSource, lines: shownLines, html: html, hasBidiControls: hasBidiControls)
+ }
+ }
+ } catch {
+ await MainActor.run {
+ showAlert(NSLocalizedString("Error reading file", comment: "alert title"), message: error.localizedDescription)
+ }
+ }
+ }
+}
+
+private func decodedText(_ data: Data) -> String {
+ var text = String.UnicodeScalarView()
+ var parser = Unicode.UTF8.ForwardParser()
+ var bytes = data.makeIterator()
+ while true {
+ switch parser.parseScalar(from: &bytes) {
+ case let .valid(encoded): text.append(replacingControlChar(Unicode.UTF8.decode(encoded)))
+ case let .error(length): text.append(contentsOf: repeatElement("?" as Unicode.Scalar, count: length))
+ case .emptyInput: return String(text).replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
+ }
+ }
+}
+
+private func replacingControlChar(_ s: Unicode.Scalar) -> Unicode.Scalar {
+ s.value < 0x20 && s != "\t" && s != "\n" && s != "\r" ? "?" : s
+}
+
+private func withinMarkdownLimits(_ lines: [String]) -> Bool {
+ var tableRows = 0
+ var linkNesting = 0
+ for line in lines {
+ tableRows = line.contains("|") ? tableRows + 1 : 0
+ if line.count > MAX_MARKDOWN_LINE_LENGTH || nestingLevel(line) > MAX_MARKDOWN_NESTING || tableRows > MAX_MARKDOWN_TABLE_ROWS || !linkNestingWithinLimit(line, &linkNesting) {
+ return false
+ }
+ }
+ return true
+}
+
+private func linkNestingWithinLimit(_ line: String, _ nesting: inout Int) -> Bool {
+ if line.isEmpty { nesting = 0 }
+ for c in line {
+ if c == "[" {
+ nesting += 1
+ if nesting > MAX_MARKDOWN_LINK_NESTING { return false }
+ } else if c == "]" && nesting > 0 {
+ nesting -= 1
+ }
+ }
+ return true
+}
+
+private func nestingLevel(_ line: String) -> Int {
+ let content = line.drop(while: { $0 == " " || $0 == "\t" || $0 == ">" })
+ let prefix = line[.." }.count
+ let listItem = content.first.map { "-*+".contains($0) || $0.isNumber } ?? false
+ return quotes + (listItem ? (prefix.count - quotes) / 2 : 0)
+}
+
+private func chunkedLine(_ line: String) -> [String] {
+ let chars = Array(line)
+ return stride(from: 0, to: max(chars.count, 1), by: MAX_MARKDOWN_LINE_LENGTH).map {
+ String(chars[$0 ..< min($0 + MAX_MARKDOWN_LINE_LENGTH, chars.count)])
+ }
+}
+
+private func escapedHTML(_ s: Substring) -> String {
+ s.replacingOccurrences(of: "&", with: "&")
+ .replacingOccurrences(of: "<", with: "<")
+ .replacingOccurrences(of: ">", with: ">")
+}
+
+private func imagesWithinBudget() -> Modifier {
+ var pixels = MAX_MARKDOWN_IMAGE_PIXELS
+ return Modifier(target: .images) { image in
+ let src = image.html.dropFirst("
"
+ }
+}
+
+private func isBidiControl(_ s: Unicode.Scalar) -> Bool {
+ [0x061C, 0x200E, 0x200F].contains(s.value) || (0x202A...0x202E).contains(s.value) || (0x2066...0x2069).contains(s.value)
+}
+
+private func withInvisibleCharMarkers(_ line: String) -> Text {
+ var text = Text(verbatim: "")
+ var plain = ""
+ for s in line.unicodeScalars {
+ if s.properties.generalCategory == .format {
+ text = text + Text(verbatim: plain) + Text(verbatim: unicodeMarker(s)).foregroundColor(.orange)
+ plain = ""
+ } else {
+ plain.unicodeScalars.append(s)
+ }
+ }
+ return text + Text(verbatim: plain)
+}
+
+private func openMarkdownFileLink(_ url: URL) {
+ let uri = url.absoluteString
+ let parsed = parseSimpleXMarkdown(uri)
+ let format = parsed?.count == 1 ? parsed?.first?.format : nil
+ switch format {
+ case let .simplexLink(_, _, simplexUri, _)?:
+ if let simplexUrl = URL(string: simplexUri) {
+ ChatModel.shared.appOpenUrl = simplexUrl
+ } else {
+ showInvalidLinkAlert(uri)
+ }
+ case .uri? where uri.hasPrefix("https://") || uri.hasPrefix("http://"):
+ openBrowserAlert(uri: uri)
+ default:
+ showInvalidLinkAlert(uri)
+ }
+}
+
+struct MarkdownFileView: View {
+ @AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW) private var textView = false
+ let file: CIFile
+ let fileSource: CryptoFile
+ let lines: [String]
+ let html: String?
+ let hasBidiControls: Bool
+
+ var body: some View {
+ NavigationView {
+ VStack(spacing: 0) {
+ if hasBidiControls {
+ Text("This file has text direction characters. The text can be shown in a different order than it is written.")
+ .foregroundColor(.orange)
+ .padding()
+ }
+ if let html, !textView {
+ ConditionsWebView(html: html, openLink: openMarkdownFileLink)
+ .padding(.horizontal)
+ } else {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ ForEach(lines.indices, id: \.self) { i in
+ withInvisibleCharMarkers(lines[i])
+ .font(.system(.body, design: .monospaced))
+ .textSelection(.enabled)
+ }
+ }
+ .padding()
+ }
+ }
+ }
+ .modifier(ThemedBackground())
+ .navigationTitle(file.fileName)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .navigationBarTrailing) {
+ if html != nil {
+ Button {
+ textView.toggle()
+ } label: {
+ Image(systemName: textView ? "doc.richtext" : "chevron.left.forwardslash.chevron.right")
+ }
+ }
+ Button {
+ saveCryptoFile(fileSource)
+ } label: {
+ Image(systemName: "square.and.arrow.up")
+ }
+ }
+ }
+ }
+ .navigationViewStyle(.stack)
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
index c9d8858fa1..f95e31369e 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
@@ -490,6 +490,7 @@ func messageText(
attrs[webLinkAttrKey] = true
handleTaps = true
}
+ t = displayedUri(t)
case let .hyperLink(text, uri):
attrs = linkAttrs()
if let text { t = text }
diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
index 56e437e5c8..ebe74b2a50 100644
--- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift
+++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
@@ -98,7 +98,7 @@ func openExternalLink(_ url: URL) {
} else {
showAlert(
title: NSLocalizedString("Open external link?", comment: "alert title"),
- message: s,
+ message: displayedUri(s),
buttonTitle: NSLocalizedString("Open", comment: "alert button"),
buttonAction: { UIApplication.shared.open(url) },
cancelButton: true
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift
index 5abbbf8d2e..6a05851c63 100644
--- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift
@@ -12,11 +12,15 @@ import WebKit
struct ConditionsWebView: UIViewRepresentable {
@State var html: String
+ var openLink: (URL) -> Void = openExternalLink
@EnvironmentObject var theme: AppTheme
@State var pageLoaded = false
func makeUIView(context: Context) -> WKWebView {
- let view = WKWebView()
+ let config = WKWebViewConfiguration()
+ config.defaultWebpagePreferences.allowsContentJavaScript = false
+ config.websiteDataStore = .nonPersistent()
+ let view = WKWebView(frame: .zero, configuration: config)
view.backgroundColor = .clear
view.isOpaque = false
view.navigationDelegate = context.coordinator
@@ -35,8 +39,9 @@ struct ConditionsWebView: UIViewRepresentable {
}
private func loadPage(_ webView: WKWebView) {
+ let nonce = UUID().uuidString
let styles = """
-
"""
- let head = "\(styles)"
+ let csp = ""
+ let head = "\(csp)\(styles)"
webView.loadHTMLString(head + html, baseURL: nil)
DispatchQueue.main.async {
pageLoaded = true
@@ -58,10 +77,16 @@ struct ConditionsWebView: UIViewRepresentable {
}
func makeCoordinator() -> Cordinator {
- Cordinator()
+ Cordinator(openLink: openLink)
}
class Cordinator: NSObject, WKNavigationDelegate {
+ let openLink: (URL) -> Void
+
+ init(openLink: @escaping (URL) -> Void) {
+ self.openLink = openLink
+ }
+
func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
@@ -71,9 +96,9 @@ struct ConditionsWebView: UIViewRepresentable {
switch navigationAction.navigationType {
case .linkActivated:
decisionHandler(.cancel)
- openExternalLink(url)
+ openLink(url)
default:
- decisionHandler(.allow)
+ decisionHandler(url.scheme == "about" ? .allow : .cancel)
}
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
index bc823a1227..b1b786c8b8 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
@@ -85,6 +85,7 @@ let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage"
+let DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView"
let DEFAULT_CURRENT_THEME = "currentTheme"
let DEFAULT_SYSTEM_DARK_THEME = "systemDarkTheme"
@@ -147,6 +148,7 @@ let appDefaults: [String: Any] = [
DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true,
DEFAULT_SHOW_SENT_VIA_RPOXY: false,
DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false,
+ DEFAULT_MARKDOWN_FILE_TEXT_VIEW: false,
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue,
DEFAULT_THEME_OVERRIDES: "{}",
diff --git a/apps/ios/SimpleX NSE/NSEAPITypes.swift b/apps/ios/SimpleX NSE/NSEAPITypes.swift
index 35a838fff9..28073ef342 100644
--- a/apps/ios/SimpleX NSE/NSEAPITypes.swift
+++ b/apps/ios/SimpleX NSE/NSEAPITypes.swift
@@ -89,6 +89,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult {
case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?)
case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest)
case newChatItems(user: UserRef, chatItems: [AChatItem])
+ case duplicateGroupMessage(user: UserRef, member: GroupMember)
case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer)
case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer)
@@ -101,6 +102,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult {
case .contactConnected: "contactConnected"
case .receivedContactRequest: "receivedContactRequest"
case .newChatItems: "newChatItems"
+ case .duplicateGroupMessage: "duplicateGroupMessage"
case .rcvFileSndCancelled: "rcvFileSndCancelled"
case .sndFileComplete: "sndFileComplete"
case .sndFileRcvCancelled: "sndFileRcvCancelled"
@@ -117,6 +119,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult {
case let .newChatItems(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
return withUser(u, itemsString)
+ case let .duplicateGroupMessage(u, member): return withUser(u, String(describing: member))
case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift
index 25df063f82..38e1b604be 100644
--- a/apps/ios/SimpleX NSE/NotificationService.swift
+++ b/apps/ios/SimpleX NSE/NotificationService.swift
@@ -73,6 +73,16 @@ public enum NSENotificationData {
default: nil
}
}
+
+ func replaces(_ bestAttempt: NSENotificationData) -> Bool {
+ switch (self, bestAttempt) {
+ case (.noNtf, .connectionEvent): true
+ case (.noNtf, _): false
+ case (.callInvitation, _): true
+ case (_, .callInvitation): false
+ default: true
+ }
+ }
}
// Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid
@@ -573,9 +583,9 @@ class NotificationService: UNNotificationServiceExtension {
// A better logic could be to check whether we have a call in the best attempt while processing .msgInfo marker above.
// If the best attempt is a call, and its marker is received, and the call is recent (e.g., the last 30 seconds), it would deliver at once,
// instead of requesting further messages.
- if ntfEntity.msgBestAttemptNtf.callInvitation == nil || ntf.callInvitation != nil {
+ if let bestAttempt = notificationEntities[id]?.msgBestAttemptNtf, ntf.replaces(bestAttempt) {
notificationEntities[id]?.msgBestAttemptNtf = ntf
- } // otherwise keep call as best attempt
+ }
return false
} else {
// We should not get to this branch, as notifications are not delivered for muted users.
@@ -1022,8 +1032,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)?
cItem = autoReceiveFile(file) ?? cItem
}
let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf
- let chatIdOrMemberId = if case let .groupRcv(groupMember) = chatItem.chatItem.chatDir {
- groupMember.id
+ let chatIdOrMemberId = if case let .group(groupInfo, _) = cInfo, let memberId = cItem.meta.forwardedByMember ?? cItem.chatDir.groupMemberId {
+ groupMemberEntityId(groupInfo.groupId, memberId)
} else {
chatItem.chatInfo.id
}
@@ -1031,6 +1041,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)?
} else {
return nil
}
+ case let .duplicateGroupMessage(_, member):
+ return (member.id, .noNtf)
case let .rcvFileSndCancelled(_, aChatItem, _):
cleanupFile(aChatItem)
return nil
diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj
index f1f9e583d3..3d9bbd111a 100644
--- a/apps/ios/SimpleX.xcodeproj/project.pbxproj
+++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj
@@ -168,6 +168,7 @@
647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; };
647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; };
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
+ 6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */; };
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; };
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; };
@@ -193,8 +194,8 @@
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
- 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */; };
- 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */; };
+ 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */; };
+ 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
@@ -562,6 +563,7 @@
647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = ""; };
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; };
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; };
+ 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownFileView.swift; sourceTree = ""; };
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; };
6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; };
@@ -588,8 +590,8 @@
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; };
- 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a"; sourceTree = ""; };
- 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a"; sourceTree = ""; };
+ 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a"; sourceTree = ""; };
+ 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a"; sourceTree = ""; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; };
@@ -764,8 +766,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
- 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */,
- 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */,
+ 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */,
+ 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -852,8 +854,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
- 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */,
- 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */,
+ 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */,
+ 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */,
);
path = Libraries;
sourceTree = "";
@@ -1129,6 +1131,7 @@
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */,
648010AA281ADD15009009B9 /* CIFileView.swift */,
+ 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */,
644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */,
3CDBCF4727FF621E00354CDD /* CILinkView.swift */,
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */,
@@ -1608,6 +1611,7 @@
5CB634A829E437960066AD6B /* PasscodeEntry.swift in Sources */,
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */,
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */,
+ 6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */,
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */,
5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */,
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */,
diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift
index a8c5de3672..186d7dab64 100644
--- a/apps/ios/SimpleXChat/ChatTypes.swift
+++ b/apps/ios/SimpleXChat/ChatTypes.swift
@@ -3255,7 +3255,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
public var relayLink: String?
public var memberVerifiedCode: SecurityCode?
- public var id: String { "#\(groupId) @\(groupMemberId)" }
+ public var id: String { groupMemberEntityId(groupId, groupMemberId) }
public var ready: Bool { get { activeConn?.connStatus == .ready } }
public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } }
public var sendMsgEnabled: Bool { get {
@@ -3444,6 +3444,10 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
)
}
+public func groupMemberEntityId(_ groupId: Int64, _ groupMemberId: Int64) -> String {
+ "#\(groupId) @\(groupMemberId)"
+}
+
public struct GroupSupportChat: Codable, Hashable {
public var chatTs: Date
public var unread: Int
@@ -4217,6 +4221,10 @@ public enum CIDirection: Decodable, Hashable {
}
}
+ public var groupMemberId: Int64? {
+ if case let .groupRcv(groupMember) = self { groupMember.groupMemberId } else { nil }
+ }
+
public func sameDirection(_ dir: CIDirection) -> Bool {
switch (self, dir) {
case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId
@@ -4242,6 +4250,7 @@ public struct CIMeta: Decodable, Hashable {
public var userMention: Bool
public var deletable: Bool
public var editable: Bool
+ public var forwardedByMember: Int64?
public var showGroupAsSender: Bool
public var msgVerified: MsgVerified?
diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md
index 7f4d98e787..a7b1fec2a3 100644
--- a/apps/ios/spec/client/chat-view.md
+++ b/apps/ios/spec/client/chat-view.md
@@ -238,6 +238,12 @@ Renders voice messages with waveform visualization, play/pause control, and dura
Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download.
+### MarkdownFileView
+
+**File**: [`Shared/Views/Chat/ChatItem/MarkdownFileView.swift`](../../Shared/Views/Chat/ChatItem/MarkdownFileView.swift)
+
+Sheet with a viewer of Markdown files up to `MAX_MARKDOWN_VIEW_SIZE` (1 MB), formatted or as text, with Save. Opened by a tap on the file item, or when reception completes after the tap that started it.
+
### [CILinkView](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14)
**File**: [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift)
diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts
index 0ff63fa8de..e9503bc6ce 100644
--- a/apps/multiplatform/common/build.gradle.kts
+++ b/apps/multiplatform/common/build.gradle.kts
@@ -77,8 +77,8 @@ kotlin {
api("dev.icerock.moko:resources-compose:0.27.1")
// Markdown
- implementation("com.mikepenz:multiplatform-markdown-renderer:0.27.0")
- implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.27.0")
+ implementation("com.mikepenz:multiplatform-markdown-renderer:0.34.0")
+ implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.34.0")
}
}
val commonTest by getting {
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt
index 1a3703822d..df2e6ccadc 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt
@@ -13,6 +13,7 @@ import androidx.core.graphics.scale
import boofcv.android.ConvertBitmap
import boofcv.struct.image.GrayU8
import chat.simplex.common.R
+import chat.simplex.common.views.helpers.decodeSampledBitmapFromByteArray
import chat.simplex.common.views.helpers.errorBitmap
import chat.simplex.common.views.helpers.getFileName
import java.io.ByteArrayOutputStream
@@ -41,6 +42,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
}
}
+actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? =
+ try {
+ decodeSampledBitmapFromByteArray(Base64.decode(base64, Base64.DEFAULT), width, width, MAX_IMAGE_DIMENSION).asImageBitmap()
+ } catch (e: Exception) {
+ Log.e(TAG, "decodeBase64Image error: $e")
+ null
+ }
+
actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String {
var img = image
var str = compressImageStr(img)
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt
new file mode 100644
index 0000000000..0bb9179299
--- /dev/null
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt
@@ -0,0 +1,7 @@
+package chat.simplex.common.views.chat
+
+import androidx.compose.runtime.*
+
+// Android keyboards have their own emoji panel
+@Composable
+actual fun ComposeEmojiButton(pickerShown: MutableState, enabled: Boolean, onEmoji: (String) -> Unit) {}
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt
index 371bd7676b..7b2f53d365 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt
@@ -194,11 +194,12 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair?
}
// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap
-private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap {
+internal fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int, maxDimension: Int = Int.MAX_VALUE): Bitmap {
// First decode with inJustDecodeBounds=true to check dimensions
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeByteArray(data, 0, data.size, this)
+ if (outWidth > maxDimension || outHeight > maxDimension) throw IOException("Image dimensions exceed limit")
// Calculate inSampleSize
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
// Decode bitmap with inSampleSize set
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt
index 656911443a..ce1152de6a 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt
@@ -415,7 +415,10 @@ fun CenterPartOfScreen() {
}
}
}
- else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {}
+ else -> {
+ ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {}
+ ModalManager.center.showInView()
+ }
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
index a4280e9d5b..65ce8a4438 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
@@ -147,6 +147,8 @@ object BadgeModel {
enum class ChatListBanner { BadgeExpired, BadgeIssueFailed, BadgePitch, GetStake }
+data class FileToOpen(val fileId: Long, val inChatList: Boolean)
+
/*
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
* */
@@ -220,8 +222,21 @@ object ChatModel {
// another in its place; only the badge alert shows regardless. Set while rendering, so not a state.
var chatListBanner: ChatListBanner? = null
+ var fileToOpen: FileToOpen? = null
+
fun bannerSlotFree(banner: ChatListBanner): Boolean = chatListBanner == null || chatListBanner == banner
+ fun openWhenReceived(file: CIFile, inChatList: Boolean) {
+ fileToOpen = FileToOpen(file.fileId, inChatList)
+ }
+
+ fun receivedFileToOpen(file: CIFile, inChatList: Boolean): Boolean {
+ val f = fileToOpen
+ if (f == null || f.fileId != file.fileId || f.inChatList != inChatList || !file.loaded) return false
+ fileToOpen = null
+ return !inChatList || appPlatform.isDesktop || chatId.value == null
+ }
+
// preferences
val notificationPreviewMode by lazy {
mutableStateOf(
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
index eced5e6cd5..e06653b5eb 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt
@@ -142,6 +142,7 @@ class AppPreferences {
val appearanceBarsBlurRadius = mkIntPreference(SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS, if (deviceSupportsBlur) 50 else 0)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false)
+ val markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
@@ -431,6 +432,7 @@ class AppPreferences {
const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites"
+ private const val SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW = "MarkdownFileTextView"
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage"
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt
index d7a241d5ee..47cc034bfb 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt
@@ -15,6 +15,7 @@ import java.io.InputStream
import java.net.URI
expect fun base64ToBitmap(base64ImageString: String): ImageBitmap
+expect fun decodeBase64Image(base64: String, width: Int): ImageBitmap?
expect fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String
expect fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream
expect fun cropToSquare(image: ImageBitmap): ImageBitmap
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt
new file mode 100644
index 0000000000..ff8482ed7b
--- /dev/null
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt
@@ -0,0 +1,14 @@
+package chat.simplex.common.views.chat
+
+import androidx.compose.runtime.*
+import androidx.compose.ui.text.TextRange
+import kotlin.math.min
+
+@Composable
+expect fun ComposeEmojiButton(pickerShown: MutableState, enabled: Boolean, onEmoji: (String) -> Unit)
+
+fun ComposeMessage.insertingEmoji(emoji: String): ComposeMessage {
+ val start = min(selection.min, text.length)
+ val end = min(selection.max, text.length)
+ return ComposeMessage(text.replaceRange(start, end, emoji), TextRange(start + emoji.length))
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt
index 92ccd92f93..357a010d2c 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt
@@ -454,6 +454,7 @@ fun ComposeView(
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) }
val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) }
+ val emojiPickerShown = rememberSaveable { mutableStateOf(false) }
AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } }
suspend fun fetchAndUpdateLinkPreview(url: String) {
@@ -1278,6 +1279,17 @@ fun ComposeView(
val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason((ownerRelayState?.noActiveRelays ?: subscriberRelayState?.noActiveRelays) == true))
val sendMsgEnabled = rememberUpdatedState(userCantSendReason.value == null)
val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv)
+ val emojiEnabled = sendMsgEnabled.value && composeState.value.preview !is ComposePreview.VoicePreview
+ LaunchedEffect(emojiEnabled) {
+ if (!emojiEnabled) emojiPickerShown.value = false
+ }
+ KeyChangeEffect(emojiPickerShown.value) {
+ if (!emojiPickerShown.value && emojiEnabled) focusRequester?.requestFocus()
+ }
+
+ fun onEmoji(emoji: String) {
+ if (!composeState.value.inProgress) onMessageChange(composeState.value.message.insertingEmoji(emoji))
+ }
@Composable
fun CommandsButton() {
@@ -1332,7 +1344,7 @@ fun ComposeView(
}
@Composable
- fun AttachmentAndCommandsButtons() {
+ fun ComposeActionButtons() {
val cInfo = chat.chatInfo
Row(
Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
@@ -1346,6 +1358,7 @@ fun ComposeView(
if (showAttachment) {
AttachmentButton()
}
+ ComposeEmojiButton(emojiPickerShown, emojiEnabled, ::onEmoji)
}
}
@@ -1784,7 +1797,7 @@ fun ComposeView(
ContextSendMessageToConnect(generalGetString(MR.strings.compose_send_direct_message_to_connect))
Divider()
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
- AttachmentAndCommandsButtons()
+ ComposeActionButtons()
SendMsgView_(
disableSendButton = disableSendButton,
sendToConnect = { withApi { sendMemberContactInvitation() } }
@@ -1839,7 +1852,7 @@ fun ComposeView(
)
} else {
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
- AttachmentAndCommandsButtons()
+ ComposeActionButtons()
val broadcastPlaceholder = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { gi ->
if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner && chat.chatInfo.groupChatScope() == null) generalGetString(MR.strings.compose_view_broadcast)
else null
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt
index a3c2b63f8f..42cbff3a2f 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt
@@ -80,6 +80,7 @@ fun CIFileView(
if (prohibited != null) {
showProhibitedFileAlert(file, prohibited)
} else {
+ chatModel.openWhenReceived(file, smallView)
receiveFile(file.fileId)
}
}
@@ -105,6 +106,8 @@ fun CIFileView(
showFileErrorAlert(file.fileStatus.sndFileError)
file.fileStatus is CIFileStatus.SndWarning ->
showFileErrorAlert(file.fileStatus.sndFileError, temporary = true)
+ isMarkdownFile(file) && file.loaded ->
+ showMarkdownFile(file)
file.forwardingAllowed() -> {
withLongRunningApi(slow = 600_000) {
var filePath = getLoadedFilePath(file)
@@ -183,21 +186,23 @@ fun CIFileView(
val showOpenSaveMenu = rememberSaveable(file?.fileId) { mutableStateOf(false) }
val ext = file?.fileSource?.filePath?.substringAfterLast(".")?.takeIf { it.isNotBlank() }
- val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null) getLoadedFilePath(file) else null
+ val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null && !isMarkdownFile(file)) getLoadedFilePath(file) else null
if (loadedFilePath != null && file?.fileSource != null) {
val encrypted = file.fileSource.cryptoArgs != null
SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() })
}
+ val onClick: () -> Unit = {
+ if (appPlatform.isAndroid && loadedFilePath != null) {
+ showOpenSaveMenu.value = true
+ } else {
+ fileAction()
+ }
+ }
+ if (file != null) OpenWhenReceivedEffect(file, smallView, onClick)
Row(
Modifier
.combinedClickable(
- onClick = {
- if (appPlatform.isAndroid && loadedFilePath != null) {
- showOpenSaveMenu.value = true
- } else {
- fileAction()
- }
- },
+ onClick = onClick,
onLongClick = { showMenu.value = true }
)
.padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())),
@@ -239,6 +244,13 @@ fun CIFileView(
// the core decides whether a received file is above the size the sender's badge allows
fun fileSizeValid(file: CIFile): Boolean = file.fileProhibited == null
+@Composable
+fun OpenWhenReceivedEffect(file: CIFile, inChatList: Boolean, open: () -> Unit) {
+ KeyChangeEffect(file.loaded) {
+ if (chatModel.receivedFileToOpen(file, inChatList)) open()
+ }
+}
+
fun showProhibitedFileAlert(file: CIFile, prohibited: FileProhibited) {
val badgeIssue = when (prohibited.badgeStatus) {
null, BadgeStatus.Active -> ""
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt
index d5ea44126b..a49884beeb 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt
@@ -173,6 +173,15 @@ fun CIImageView(
return null
}
+ val view = LocalMultiplatformView()
+ if (file != null) {
+ OpenWhenReceivedEffect(file, smallView) {
+ hideKeyboard(view)
+ ModalManager.fullscreen.showCustomModal(animated = false) { close ->
+ ImageFullScreenView(imageProvider, close)
+ }
+ }
+ }
Box(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID)
.then(
@@ -222,6 +231,7 @@ fun CIImageView(
if (file.fileProhibited != null) {
showProhibitedFileAlert(file, file.fileProhibited)
} else {
+ chatModel.openWhenReceived(file, smallView)
receiveFile(file.fileId)
}
file.fileStatus is CIFileStatus.RcvAccepted ->
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt
index 21f406db18..62e1b9f737 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt
@@ -70,6 +70,8 @@ fun CIVideoView(
}
}
val f = filePath.value
+ val autoPlay = remember { mutableStateOf(false) }
+ if (file != null) OpenWhenReceivedEffect(file, smallView) { autoPlay.value = true }
if (file != null && f != null) {
val view = LocalMultiplatformView()
val openFullscreen = {
@@ -80,7 +82,6 @@ fun CIVideoView(
}
val uri = remember(filePath) { getAppFileUri(f.substringAfterLast(File.separator)) }
- val autoPlay = remember { mutableStateOf(false) }
val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) }
val decrypted = uriDecrypted.value
if (decrypted != null && smallView) {
@@ -98,7 +99,7 @@ fun CIVideoView(
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted ->
- receiveFileIfValidSize(file, receiveFile)
+ receiveFileIfValidSize(file, smallView, receiveFile)
CIFileStatus.RcvAccepted ->
when (file.fileProtocol) {
FileProtocol.XFTP ->
@@ -128,7 +129,7 @@ fun CIVideoView(
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
}
if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) {
- PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
+ PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, smallView, receiveFile) }
}
}
}
@@ -156,22 +157,26 @@ private fun VideoViewEncrypted(
) {
var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) }
val onLongClick = { showMenu.value = true }
+ val play: () -> Unit = {
+ decryptionInProgress = true
+ withBGApi {
+ try {
+ uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate()
+ autoPlay.value = uriUnencrypted.value != null
+ } finally {
+ decryptionInProgress = false
+ }
+ }
+ }
+ LaunchedEffect(autoPlay.value) {
+ if (autoPlay.value) play()
+ }
Box {
VideoPreviewImageView(defaultPreview, smallView = false, blurred = blurred, if (decryptionInProgress) {{}} else openFullscreen, onLongClick)
if (decryptionInProgress) {
VideoDecryptionProgress(1f, onLongClick = onLongClick)
} else if (!blurred.value) {
- PlayButton(false, 1f, onLongClick = onLongClick) {
- decryptionInProgress = true
- withBGApi {
- try {
- uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate()
- autoPlay.value = uriUnencrypted.value != null
- } finally {
- decryptionInProgress = false
- }
- }
- }
+ PlayButton(false, 1f, onLongClick = onLongClick, onClick = play)
}
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(defaultDuration) }, remember { mutableStateOf(0L) })
}
@@ -190,22 +195,26 @@ private fun SmallVideoViewEncrypted(
) {
var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) }
val onLongClick = { showMenu.value = true }
+ val play: () -> Unit = {
+ decryptionInProgress = true
+ withBGApi {
+ try {
+ uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate()
+ autoPlay.value = uriUnencrypted.value != null
+ } finally {
+ decryptionInProgress = false
+ }
+ }
+ }
+ LaunchedEffect(autoPlay.value) {
+ if (autoPlay.value) play()
+ }
Box {
VideoPreviewImageView(defaultPreview, smallView = true, blurred = blurred, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick)
if (decryptionInProgress) {
VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick)
} else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
- PlayButton(false, sizeMultiplier, onLongClick = onLongClick) {
- decryptionInProgress = true
- withBGApi {
- try {
- uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate()
- autoPlay.value = uriUnencrypted.value != null
- } finally {
- decryptionInProgress = false
- }
- }
- }
+ PlayButton(false, sizeMultiplier, onLongClick = onLongClick, onClick = play)
}
}
}
@@ -240,7 +249,7 @@ private fun SmallVideoView(
PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen)
}
}
- LaunchedEffect(uri) {
+ LaunchedEffect(uri, autoPlay.value) {
if (autoPlay.value) openFullscreen()
}
}
@@ -272,7 +281,7 @@ private fun VideoView(
player.stop()
}
val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } }
- LaunchedEffect(uri) {
+ LaunchedEffect(uri, autoPlay.value) {
if (autoPlay.value) play()
}
// Drop autoPlay only when show preview changes to prevent blinking of the view
@@ -566,10 +575,11 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
private fun showDownloadButton(status: CIFileStatus?): Boolean =
status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted
-private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
+private fun receiveFileIfValidSize(file: CIFile, smallView: Boolean, receiveFile: (Long) -> Unit) {
if (file.fileProhibited != null) {
showProhibitedFileAlert(file, file.fileProhibited)
} else {
+ chatModel.openWhenReceived(file, smallView)
receiveFile(file.fileId)
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt
index 3b3c835e0a..5aeefd9668 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt
@@ -87,7 +87,7 @@ fun CIVoiceView(
durationText(time / 1000)
}
}
- VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, receiveFile) {
+ VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, { chatModel.openWhenReceived(file, smallView); receiveFile(it) }) {
AudioPlayer.seekTo(it, progress, fileSource.value?.filePath)
}
if (smallView) {
@@ -95,6 +95,7 @@ fun CIVoiceView(
AudioPlayer.stop()
}
}
+ OpenWhenReceivedEffect(file, smallView, play)
} else if (smallView) {
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, sizeMultiplier, {}, {}, longClick, receiveFile)
} else {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt
new file mode 100644
index 0000000000..189e69a925
--- /dev/null
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt
@@ -0,0 +1,284 @@
+package chat.simplex.common.views.chat.item
+
+import SectionBottomSpacer
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.platform.UriHandler
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import chat.simplex.common.model.*
+import chat.simplex.common.model.ChatController.appPrefs
+import chat.simplex.common.platform.*
+import chat.simplex.common.ui.theme.DEFAULT_PADDING
+import chat.simplex.common.ui.theme.WarningOrange
+import chat.simplex.common.views.chat.topPaddingToContent
+import chat.simplex.common.views.helpers.*
+import chat.simplex.common.views.usersettings.networkAndServers.documentMarkdownStyle
+import chat.simplex.res.*
+import com.mikepenz.markdown.compose.MarkdownElement
+import com.mikepenz.markdown.m2.Markdown
+import com.mikepenz.markdown.model.ImageData
+import com.mikepenz.markdown.model.ImageTransformer
+import com.mikepenz.markdown.model.State
+import com.mikepenz.markdown.model.markdownAnnotator
+import com.mikepenz.markdown.model.parseMarkdownFlow
+import dev.icerock.moko.resources.compose.painterResource
+import dev.icerock.moko.resources.compose.stringResource
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+import org.intellij.markdown.MarkdownElementTypes
+import org.intellij.markdown.ast.ASTNode
+import org.intellij.markdown.ast.getTextInNode
+import org.intellij.markdown.flavours.gfm.GFMElementTypes
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.net.URI
+import java.nio.ByteBuffer
+import java.nio.charset.CodingErrorAction
+
+private const val DATA_IMAGE = "data:image/"
+private const val MAX_MARKDOWN_VIEW_SIZE: Long = 1_048_576
+private const val MAX_MARKDOWN_LINE_LENGTH = 15_000
+private const val MAX_MARKDOWN_DEPTH = 32
+private const val MAX_MARKDOWN_TABLE_ROWS = 200
+private const val MAX_MARKDOWN_IMAGE_PIXELS = 2160L * 4320
+
+private val controlChar = Regex("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]")
+
+private val bidiControlChar = Regex("[\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]")
+
+private val invisibleCharMarkerStyle = SpanStyle(color = WarningOrange)
+
+private class MarkdownDocument(val lines: List, val formatted: State.Success?, val hasBidiControls: Boolean)
+
+fun isMarkdownFile(file: CIFile): Boolean =
+ file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && file.fileName.substringAfterLast('.', "").lowercase() in listOf("md", "markdown")
+
+fun showMarkdownFile(file: CIFile) {
+ withLongRunningApi(slow = 600_000) {
+ var filePath = getLoadedFilePath(file)
+ if (chatModel.connectedToRemote() && filePath == null) {
+ file.loadRemoteFile(true)
+ filePath = getLoadedFilePath(file)
+ }
+ if (filePath == null) {
+ AlertManager.shared.showAlertMsg(MR.strings.file_not_found)
+ return@withLongRunningApi
+ }
+ val bytes = try {
+ val cryptoArgs = file.fileSource?.cryptoArgs
+ if (cryptoArgs != null) readCryptoFile(filePath, cryptoArgs) else File(filePath).readBytes()
+ } catch (e: Exception) {
+ AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.message)
+ return@withLongRunningApi
+ }
+ val text = decodedText(bytes)
+ val lines = text.lines()
+ val formatted = if (lines.all { it.length <= MAX_MARKDOWN_LINE_LENGTH }) formattedWithinLimits(text) else null
+ val document = MarkdownDocument(
+ lines.flatMap { line -> line.chunked(MAX_MARKDOWN_LINE_LENGTH).ifEmpty { listOf("") } },
+ formatted,
+ bidiControlChar.containsMatchIn(text)
+ )
+ ModalManager.center.showModal(endButtons = {
+ if (formatted != null) MarkdownViewSwitch()
+ MarkdownFileSaveButton(file, bytes)
+ }) {
+ MarkdownFileView(file.fileName, document)
+ }
+ }
+}
+
+private fun decodedText(bytes: ByteArray): String =
+ Charsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPLACE)
+ .onUnmappableCharacter(CodingErrorAction.REPLACE)
+ .replaceWith("?")
+ .decode(ByteBuffer.wrap(bytes))
+ .toString()
+ .replace(controlChar, "?")
+
+private suspend fun formattedWithinLimits(text: String): State.Success? =
+ (parseMarkdownFlow(text).first { it !is State.Loading } as? State.Success)?.takeIf { treeWithinLimits(it.node) }
+
+private fun treeWithinLimits(root: ASTNode): Boolean {
+ val nodes = ArrayDeque(listOf(root to 1))
+ while (nodes.isNotEmpty()) {
+ val (node, depth) = nodes.removeLast()
+ if (depth > MAX_MARKDOWN_DEPTH) return false
+ if (node.type == GFMElementTypes.TABLE && node.children.count { it.type == GFMElementTypes.ROW } > MAX_MARKDOWN_TABLE_ROWS) return false
+ node.children.forEach { nodes.addLast(it to depth + 1) }
+ }
+ return true
+}
+
+@Composable
+private fun MarkdownViewSwitch() {
+ val textView = remember { appPrefs.markdownFileTextView.state }
+ IconButton({ appPrefs.markdownFileTextView.set(!textView.value) }) {
+ Icon(
+ painterResource(if (textView.value) MR.images.ic_article else MR.images.ic_code),
+ stringResource(if (textView.value) MR.strings.markdown_formatted_view else MR.strings.markdown_text_view),
+ tint = MaterialTheme.colors.primary
+ )
+ }
+}
+
+@Composable
+private fun MarkdownFileSaveButton(file: CIFile, bytes: ByteArray) {
+ val saveFileLauncher = rememberFileChooserLauncher(false, bytes) { to: URI? ->
+ if (to != null) copyBytesToFile(ByteArrayInputStream(bytes), to) {}
+ }
+ IconButton({ withLongRunningApi { saveFileLauncher.launch(file.fileName) } }) {
+ Icon(painterResource(MR.images.ic_download), stringResource(MR.strings.save_verb), tint = MaterialTheme.colors.primary)
+ }
+}
+
+@Composable
+private fun MarkdownFileView(fileName: String, document: MarkdownDocument) {
+ val textView = remember { appPrefs.markdownFileTextView.state }
+ val formatted = document.formatted
+ if (textView.value || formatted == null) {
+ SelectionContainer {
+ MarkdownFileColumn(fileName, document.hasBidiControls) {
+ items(document.lines) { line ->
+ Text(remember(line) { withInvisibleCharMarkers(line) }, Modifier.padding(horizontal = DEFAULT_PADDING), fontFamily = FontFamily.Monospace)
+ }
+ }
+ }
+ } else {
+ FormattedMarkdown(fileName, formatted, document.hasBidiControls)
+ }
+}
+
+private fun withInvisibleCharMarkers(line: String): AnnotatedString = buildAnnotatedString {
+ var start = 0
+ invisibleChar.findAll(line).forEach {
+ append(line.substring(start, it.range.first))
+ withStyle(invisibleCharMarkerStyle) { append(unicodeMarker(it.value)) }
+ start = it.range.last + 1
+ }
+ append(line.substring(start))
+}
+
+@Composable
+private fun FormattedMarkdown(fileName: String, formatted: State.Success, hasBidiControls: Boolean) {
+ val style = documentMarkdownStyle(h1 = MaterialTheme.typography.h2.copy(fontSize = 26.sp, fontWeight = FontWeight.Bold))
+ val codeSpanStyle = style.typography.inlineCode.toSpanStyle().copy(background = style.colors.inlineCodeBackground)
+ val annotator = remember(codeSpanStyle) {
+ markdownAnnotator { content, child ->
+ when (child.type) {
+ MarkdownElementTypes.CODE_SPAN -> {
+ withStyle(codeSpanStyle) { append(" ${child.getTextInNode(content).trim('`')} ") }
+ true
+ }
+ MarkdownElementTypes.IMAGE ->
+ if (imageLink(content, child)?.startsWith(DATA_IMAGE) == true) {
+ false
+ } else {
+ append(child.getTextInNode(content).toString())
+ true
+ }
+ else -> false
+ }
+ }
+ }
+ val imageTransformer = remember { DataImageTransformer() }
+ val parentUriHandler = LocalUriHandler.current
+ CompositionLocalProvider(LocalUriHandler provides remember { markdownFileUriHandler(parentUriHandler) }) {
+ Markdown(
+ formatted,
+ colors = style.colors,
+ typography = style.typography,
+ padding = style.padding,
+ imageTransformer = imageTransformer,
+ annotator = annotator,
+ components = style.components,
+ success = { state, components, _ ->
+ MarkdownFileColumn(fileName, hasBidiControls) {
+ items(state.node.children) { node ->
+ Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
+ MarkdownElement(node, components, state.content, skipLinkDefinition = state.linksLookedUp)
+ }
+ }
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun MarkdownFileColumn(fileName: String, hasBidiControls: Boolean, content: LazyListScope.() -> Unit) {
+ val oneHandUI = remember { appPrefs.oneHandUI.state }
+ LazyColumnWithScrollBar(
+ contentPadding = PaddingValues(
+ top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp else topPaddingToContent(false)
+ )
+ ) {
+ item { AppBarTitle(fileName) }
+ if (hasBidiControls) {
+ item {
+ Text(stringResource(MR.strings.markdown_file_bidi_warning), Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING), color = WarningOrange)
+ }
+ }
+ content()
+ item { SectionBottomSpacer() }
+ }
+}
+
+private fun imageLink(content: String, image: ASTNode): String? =
+ image.children.firstOrNull { it.type == MarkdownElementTypes.INLINE_LINK }
+ ?.children?.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION }
+ ?.getTextInNode(content)?.toString()
+
+private fun markdownFileUriHandler(parentUriHandler: UriHandler): UriHandler = object: UriHandler {
+ override fun openUri(uri: String) {
+ val format = parseToMarkdown(uri)?.singleOrNull()?.format
+ when {
+ format is Format.SimplexLink -> parentUriHandler.openVerifiedSimplexUri(format.simplexUri)
+ format is Format.Uri && (uri.startsWith("https://") || uri.startsWith("http://")) -> openBrowserAlert(uri, parentUriHandler)
+ else -> showInvalidLinkAlert(uri)
+ }
+ }
+}
+
+private class DataImageTransformer : ImageTransformer {
+ private var pixels = MAX_MARKDOWN_IMAGE_PIXELS
+ private val images = mutableMapOf()
+
+ @Composable
+ override fun transform(link: String): ImageData? {
+ val width = with(LocalDensity.current) { LocalWindowWidth().roundToPx() }
+ val bitmap by produceState(null, link) {
+ value = withContext(Dispatchers.Default) { decodedImage(link, width) }
+ }
+ return remember(bitmap) { bitmap?.let { ImageData(BitmapPainter(it)) } }
+ }
+
+ private fun decodedImage(link: String, width: Int): ImageBitmap? = synchronized(this) {
+ if (link !in images) images[link] = imageWithinBudget(link, width)
+ images[link]
+ }
+
+ private fun imageWithinBudget(link: String, width: Int): ImageBitmap? =
+ link.takeIf { it.startsWith(DATA_IMAGE) }
+ ?.let { decodeBase64Image(it.substringAfter("base64,"), width) }
+ ?.takeIf { it.width.toLong() * it.height <= pixels }
+ ?.also { pixels -= it.width.toLong() * it.height }
+}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt
index da64862c03..c198451464 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt
@@ -96,6 +96,7 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin
} else if (mention != null) mentionText(ft.format.memberName)
else ft.text
}
+ is Format.Uri -> displayedUri(ft.text)
is Format.HyperLink -> ft.format.showText ?: ft.text
is Format.SimplexLink -> {
val t = ft.format.showText
@@ -283,7 +284,7 @@ fun MarkdownText (
val s = ft.text
val link = if (s.startsWith("http://") || s.startsWith("https://")) s else "https://$s"
withAnnotation(tag = "WEB_URL", annotation = link) {
- withStyle(ftStyle) { append(ft.text) }
+ withStyle(ftStyle) { append(displayedUri(ft.text)) }
}
}
is Format.HyperLink -> {
@@ -535,19 +536,22 @@ fun openBrowserAlert(uri: String, uriHandler: UriHandler) {
if (res == null) {
showInvalidLinkAlert(uri, err)
} else {
- val message = if (uri.count() > 160) uri.substring(0, 159) + "…" else uri
+ val shownUri = displayedUri(uri)
+ val message = if (shownUri.count() > 160) shownUri.substring(0, 159) + "…" else shownUri
val sanitizedUri = res.second
if (sanitizedUri == null) {
AlertManager.shared.showAlertDialog(
generalGetString(MR.strings.privacy_chat_list_open_web_link_question),
message,
confirmText = generalGetString(MR.strings.open_verb),
- onConfirm = { safeOpenUri(uri, uriHandler) }
+ onConfirm = { safeOpenUri(uri, uriHandler) },
+ parseHtml = false
)
} else {
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.privacy_chat_list_open_web_link_question),
message,
+ parseHtml = false,
buttons = {
Column {
SectionItemView({
@@ -585,7 +589,7 @@ fun safeOpenUri(uri: String, uriHandler: UriHandler) {
}
fun showInvalidLinkAlert(uri: String, error: String? = null) {
- val message = if (error.isNullOrEmpty()) { uri } else { error + "\n" + uri }
+ val message = if (error.isNullOrEmpty()) { displayedUri(uri) } else { error + "\n" + displayedUri(uri) }
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_parsing_uri_title), message)
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt
index 489ebb8c39..32e0befa3f 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt
@@ -586,7 +586,7 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection
onClick = {
ModalManager.center.closeModals()
ModalManager.end.closeModals()
- ModalManager.center.showModalCloseable(settings = true, showClose = appPlatform.isAndroid, cardScreen = true) { close ->
+ ModalManager.center.showModalCloseable(settings = true, cardScreen = true) { close ->
ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, true, close)
}
showMenu.value = false
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt
index 652d072c9c..ea567d7da6 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt
@@ -217,23 +217,43 @@ fun UserPicker(
@Composable
fun SecondSection() {
- UserPickerOptionRow(
- painterResource(MR.images.ic_qr_code),
- if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address),
- showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped
- )
- UserPickerOptionRow(
- painterResource(MR.images.ic_toggle_on),
- stringResource(MR.strings.chat_preferences),
- click = if (stopped) null else ({
- showCustomModal { m, close ->
- PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
- }()
- }),
- disabled = stopped
- )
+ if (!chatModel.desktopNoUserNoRemote) {
+ UserPickerOptionRow(
+ painterResource(MR.images.ic_qr_code),
+ if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address),
+ showCustomModal { it, close ->
+ val user = it.currentUser.value
+ if (user != null) {
+ UserAddressView(it, shareViaProfile = user.addressShared, close = close)
+ } else {
+ val modal = this
+ LaunchedEffect(modal, ModalManager.start.modalCount.value) {
+ if (ModalManager.start.isLastModal(modal)) close()
+ }
+ }
+ }, disabled = stopped
+ )
+ UserPickerOptionRow(
+ painterResource(MR.images.ic_toggle_on),
+ stringResource(MR.strings.chat_preferences),
+ click = if (stopped) null else ({
+ showCustomModal { m, close ->
+ val user = m.currentUser.value
+ if (user != null) {
+ PreferencesView(m, user, close)
+ } else {
+ val modal = this
+ LaunchedEffect(modal, ModalManager.start.modalCount.value) {
+ if (ModalManager.start.isLastModal(modal)) close()
+ }
+ }
+ }()
+ }),
+ disabled = stopped
+ )
+ }
if (appPlatform.isDesktop) {
- Divider(Modifier.padding(DEFAULT_PADDING))
+ if (!chatModel.desktopNoUserNoRemote) Divider(Modifier.padding(DEFAULT_PADDING))
val inactiveUsers = users.filter { !it.user.activeUser }
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt
index 02c0b45de4..f8e81c8ee2 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt
@@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import chat.simplex.common.model.ChatController.appPrefs
-import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import LocalCardScreen
import chat.simplex.common.ui.theme.*
@@ -116,6 +115,8 @@ class ModalManager(private val placement: ModalPlacement? = null) {
fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id
+ fun isLastModal(data: ModalData): Boolean = modalViews.getOrNull(modalViews.size - toRemove.size - 1)?.data === data
+
fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
showCustomModal(id = id, forceAnimated = forceAnimated) { close ->
ModalView(close, showClose = showClose, cardScreen = cardScreen, endButtons = endButtons, content = { content() })
@@ -142,9 +143,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
modalViews.add(ModalViewHolder(id, anim, data, modal))
_modalCount.value = modalViews.size - toRemove.size
- if (placement == ModalPlacement.CENTER) {
- ChatModel.chatId.value = null
- } else if (placement == ModalPlacement.END) {
+ if (placement == ModalPlacement.END) {
desktopExpandWindowToWidth(DEFAULT_START_MODAL_WIDTH * sqrt(appPrefs.fontScale.get()) + DEFAULT_MIN_CENTER_MODAL_WIDTH + DEFAULT_END_MODAL_WIDTH * sqrt(appPrefs.fontScale.get()))
}
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt
index dfe33e3595..81a64cb527 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt
@@ -617,13 +617,27 @@ fun UriHandler.openExternalLink(uri: String) {
} else {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.open_external_link_title),
- text = uri,
+ text = displayedUri(uri),
confirmText = generalGetString(MR.strings.open_verb),
- onConfirm = { uriHandler.openUriCatching(uri) }
+ onConfirm = { uriHandler.openUriCatching(uri) },
+ parseHtml = false
)
}
}
+val invisibleChar = Regex("\\p{Cf}")
+
+private val nonAsciiChar = Regex("[^\\x00-\\x7F]")
+
+fun unicodeMarker(c: String): String = "[U+" + Integer.toHexString(c.codePointAt(0)).uppercase().padStart(4, '0') + "]"
+
+fun displayedUri(uri: String): String {
+ val authorityStart = uri.indexOf("://").takeIf { it >= 0 }?.plus(3) ?: 0
+ val authorityEnd = uri.indexOfAny(charArrayOf('/', '?', '#'), authorityStart).takeIf { it >= 0 } ?: uri.length
+ val authority = uri.substring(authorityStart, authorityEnd).replace(nonAsciiChar) { unicodeMarker(it.value) }
+ return uri.replaceRange(authorityStart, authorityEnd, authority).replace(invisibleChar) { unicodeMarker(it.value) }
+}
+
fun IntSize.Companion.Saver(): Saver = Saver(
save = { it.width to it.height },
restore = { IntSize(it.first, it.second) }
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt
index 1e46091ea3..20a182551c 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt
@@ -33,10 +33,14 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.*
import chat.simplex.res.*
import com.mikepenz.markdown.compose.Markdown
+import com.mikepenz.markdown.compose.components.MarkdownComponents
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownHeader
import com.mikepenz.markdown.m2.markdownColor
import com.mikepenz.markdown.m2.markdownTypography
+import com.mikepenz.markdown.model.MarkdownColors
+import com.mikepenz.markdown.model.MarkdownPadding
+import com.mikepenz.markdown.model.MarkdownTypography
import com.mikepenz.markdown.model.markdownPadding
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -771,22 +775,36 @@ expect fun ConditionsBox(modifier: Modifier, scrollState: ScrollState, content:
@Composable
private fun ConditionsMarkdown(text: String) {
+ val style = documentMarkdownStyle()
Markdown(text,
- markdownColor(linkText = MaterialTheme.colors.primary),
+ style.colors,
+ style.typography,
+ Modifier.padding(8.dp),
+ // using CommonMarkFlavourDescriptor instead of GFMFlavourDescriptor because it shows `https://simplex.chat/` (link inside backticks) incorrectly
+ flavour = CommonMarkFlavourDescriptor(),
+ components = style.components,
+ padding = style.padding
+ )
+}
+
+class DocumentMarkdownStyle(val colors: MarkdownColors, val typography: MarkdownTypography, val components: MarkdownComponents, val padding: MarkdownPadding)
+
+@Composable
+fun documentMarkdownStyle(h1: TextStyle = MaterialTheme.typography.body1): DocumentMarkdownStyle =
+ DocumentMarkdownStyle(
+ markdownColor(),
markdownTypography(
- h1 = MaterialTheme.typography.body1,
+ h1 = h1,
h2 = MaterialTheme.typography.h3.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold),
h3 = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold),
h4 = MaterialTheme.typography.h5.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold),
h5 = MaterialTheme.typography.h6.copy(fontWeight = FontWeight.Bold),
link = MaterialTheme.typography.body1.copy(
+ color = MaterialTheme.colors.primary,
textDecoration = TextDecoration.Underline
)
),
- Modifier.padding(8.dp),
- // using CommonMarkFlavourDescriptor instead of GFMFlavourDescriptor because it shows `https://simplex.chat/` (link inside backticks) incorrectly
- flavour = CommonMarkFlavourDescriptor(),
- components = markdownComponents(
+ markdownComponents(
heading2 = {
Spacer(Modifier.height(10.dp))
MarkdownHeader(it.content, it.node, it.typography.h2)
@@ -803,9 +821,8 @@ private fun ConditionsMarkdown(text: String) {
Spacer(Modifier.height(4.dp))
},
),
- padding = markdownPadding(block = 4.dp)
+ markdownPadding(block = 4.dp)
)
-}
@Composable
private fun ConditionsLinkView(conditionsLink: String) {
diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml
index 5c505ed819..2f6a768a5e 100644
--- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml
@@ -597,6 +597,17 @@
Attach
+ Emoji
+ Smileys & emotion
+ People & body
+ Animals & nature
+ Food & drink
+ Travel & places
+ Activities
+ Objects
+ Symbols
+ Flags
+ No emoji found
Context icon
Cancel image preview
Cancel file preview
@@ -697,6 +708,9 @@
File saved
File not found
Error saving file
+ Formatted view
+ Text view
+ This file has text direction characters. The text can be shown in a different order than it is written.
Loading the file
Please, wait while the file is being loaded from the linked mobile
File error
diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ComposeEmojiTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ComposeEmojiTest.kt
new file mode 100644
index 0000000000..da2fecff60
--- /dev/null
+++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ComposeEmojiTest.kt
@@ -0,0 +1,42 @@
+package chat.simplex.app
+
+import androidx.compose.ui.text.TextRange
+import chat.simplex.common.views.chat.ComposeMessage
+import chat.simplex.common.views.chat.insertingEmoji
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+// use this command for testing:
+// ./gradlew desktopTest
+class ComposeEmojiTest {
+ @Test
+ fun testInsertIntoEmptyMessage() {
+ assertEquals(ComposeMessage("😀", TextRange(2)), ComposeMessage().insertingEmoji("😀"))
+ }
+
+ @Test
+ fun testInsertAtCursor() {
+ assertEquals(ComposeMessage("hi😀 there", TextRange(4)), ComposeMessage("hi there", TextRange(2)).insertingEmoji("😀"))
+ assertEquals(ComposeMessage("😀hi", TextRange(2)), ComposeMessage("hi", TextRange(0)).insertingEmoji("😀"))
+ assertEquals(ComposeMessage("hi👍", TextRange(4)), ComposeMessage("hi").insertingEmoji("👍"))
+ }
+
+ @Test
+ fun testReplaceSelection() {
+ assertEquals(ComposeMessage("say ❤️!", TextRange(6)), ComposeMessage("say love!", TextRange(4, 8)).insertingEmoji("❤️"))
+ // selection made right to left has start > end
+ assertEquals(ComposeMessage("say ❤️!", TextRange(6)), ComposeMessage("say love!", TextRange(8, 4)).insertingEmoji("❤️"))
+ }
+
+ @Test
+ fun testInsertAfterEmoji() {
+ val flag = "🏴"
+ val withFlag = ComposeMessage(flag)
+ assertEquals(ComposeMessage(flag + "👩💻", TextRange(flag.length + "👩💻".length)), withFlag.insertingEmoji("👩💻"))
+ }
+
+ @Test
+ fun testSelectionBeyondTextIsClamped() {
+ assertEquals(ComposeMessage("ab😀", TextRange(4)), ComposeMessage("ab", TextRange(5)).insertingEmoji("😀"))
+ }
+}
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt
index 9c6139b493..925698154e 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt
@@ -5,6 +5,7 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import boofcv.io.image.ConvertBufferedImage
import boofcv.struct.image.GrayU8
+import chat.simplex.common.views.helpers.decodeBoundedBufferedImage
import chat.simplex.res.*
import org.jetbrains.skia.Image
import java.awt.RenderingHints
@@ -54,6 +55,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
}
}
+actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? =
+ try {
+ decodeBoundedBufferedImage(Base64.getMimeDecoder().decode(base64), width).toComposeImageBitmap()
+ } catch (e: Throwable) {
+ Log.e(TAG, "decodeBase64Image error: $e")
+ null
+ }
+
actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String {
var img = image
var str = compressImageStr(img)
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt
new file mode 100644
index 0000000000..65a3bb8c5a
--- /dev/null
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt
@@ -0,0 +1,248 @@
+package chat.simplex.common.views.chat
+
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.input.key.*
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInWindow
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.*
+import androidx.compose.ui.window.*
+import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
+import chat.simplex.common.ui.theme.EmojiFont
+import chat.simplex.common.views.helpers.KeyChangeEffect
+import chat.simplex.common.views.helpers.SearchTextField
+import chat.simplex.res.*
+import dev.icerock.moko.resources.StringResource
+import dev.icerock.moko.resources.compose.painterResource
+import dev.icerock.moko.resources.compose.stringResource
+import kotlinx.coroutines.launch
+
+private const val EMOJI_DATA_RESOURCE = "/emoji/emoji.txt"
+private const val EMOJI_GROUP_PREFIX = "@"
+private val EMOJI_POPUP_WIDTH = 360.dp
+private val EMOJI_POPUP_HEIGHT = 420.dp
+private val EMOJI_CELL_SIZE = 38.dp
+private val EMOJI_SIZE = 26.dp
+private val EMOJI_TAB_SIZE = 32.dp
+private val EMOJI_TAB_ICON_SIZE = 20.dp
+// the buttons left of the message field overlap by 8 dp, this keeps the emoji button a little apart
+private val EMOJI_BUTTON_START_PADDING = 4.dp
+
+enum class EmojiCategory(val unicodeGroup: String, val icon: String, val title: StringResource) {
+ Smileys("Smileys & Emotion", "😀", MR.strings.emoji_category_smileys),
+ People("People & Body", "👋", MR.strings.emoji_category_people),
+ Animals("Animals & Nature", "🐻", MR.strings.emoji_category_animals),
+ Food("Food & Drink", "🍔", MR.strings.emoji_category_food),
+ Travel("Travel & Places", "🚗", MR.strings.emoji_category_travel),
+ Activities("Activities", "⚽", MR.strings.emoji_category_activities),
+ Objects("Objects", "💡", MR.strings.emoji_category_objects),
+ Symbols("Symbols", "🔣", MR.strings.emoji_category_symbols),
+ Flags("Flags", "🏁", MR.strings.emoji_category_flags),
+}
+
+data class EmojiData(val emoji: String, val name: String)
+
+data class EmojiSection(val category: EmojiCategory, val emojis: List)
+
+fun parseEmojiData(lines: Sequence): List {
+ val sections = mutableListOf()
+ var emojis = mutableListOf()
+ for (line in lines) {
+ if (line.isEmpty()) continue
+ if (line.startsWith(EMOJI_GROUP_PREFIX)) {
+ val group = line.removePrefix(EMOJI_GROUP_PREFIX)
+ val category = EmojiCategory.entries.firstOrNull { it.unicodeGroup == group }
+ ?: throw IllegalArgumentException("unknown emoji group: $group")
+ emojis = mutableListOf()
+ sections.add(EmojiSection(category, emojis))
+ } else {
+ val (emoji, name) = line.split("\t", limit = 2)
+ emojis.add(EmojiData(emoji, name))
+ }
+ }
+ return sections
+}
+
+val emojiSections: List by lazy {
+ val stream = EmojiCategory::class.java.getResourceAsStream(EMOJI_DATA_RESOURCE)
+ ?: throw IllegalStateException("missing resource $EMOJI_DATA_RESOURCE")
+ stream.bufferedReader().useLines(::parseEmojiData)
+}
+
+fun searchEmoji(sections: List, query: String): List {
+ val q = query.trim()
+ return sections.flatMap { s -> s.emojis.filter { it.name.contains(q, ignoreCase = true) } }
+}
+
+@Composable
+actual fun ComposeEmojiButton(pickerShown: MutableState, enabled: Boolean, onEmoji: (String) -> Unit) {
+ // the popup opens above the button, so in a short window it must not be taller than the space there
+ var spaceAbove by remember { mutableStateOf(EMOJI_POPUP_HEIGHT) }
+ val density = LocalDensity.current
+ Box(Modifier.padding(start = EMOJI_BUTTON_START_PADDING).onGloballyPositioned { spaceAbove = with(density) { it.positionInWindow().y.toDp() } }) {
+ EmojiIconButton(painterResource(MR.images.ic_add_reaction), stringResource(MR.strings.icon_descr_emoji), enabled) {
+ pickerShown.value = !pickerShown.value
+ }
+ if (pickerShown.value) {
+ Popup(
+ popupPositionProvider = AboveAnchorPositionProvider,
+ onDismissRequest = { pickerShown.value = false },
+ properties = PopupProperties(focusable = true),
+ // closing on KeyUp, as the window does, so that the same key release does not also close the top modal
+ onKeyEvent = {
+ if (it.key == Key.Escape) {
+ if (it.type == KeyEventType.KeyUp) pickerShown.value = false
+ true
+ } else false
+ }
+ ) {
+ EmojiPicker(height = minOf(EMOJI_POPUP_HEIGHT, spaceAbove), onEmoji = onEmoji)
+ }
+ }
+ }
+}
+
+private object AboveAnchorPositionProvider : PopupPositionProvider {
+ override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset {
+ val x = if (layoutDirection == LayoutDirection.Ltr) anchorBounds.left else anchorBounds.right - popupContentSize.width
+ return IntOffset(
+ x.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width)),
+ (anchorBounds.top - popupContentSize.height).coerceAtLeast(0)
+ )
+ }
+}
+
+@Composable
+private fun EmojiPicker(height: Dp, onEmoji: (String) -> Unit) {
+ val sections = remember { emojiSections }
+ val query = remember { mutableStateOf("") }
+ val found = remember { derivedStateOf { if (query.value.isBlank()) null else searchEmoji(sections, query.value) } }
+ val gridState = rememberLazyGridState()
+ KeyChangeEffect(found.value) { gridState.scrollToItem(0) }
+ val headerIndexes = remember(sections) {
+ var index = 0
+ sections.map { s -> index.also { index += s.emojis.size + 1 } }
+ }
+ Surface(
+ Modifier.size(EMOJI_POPUP_WIDTH, height),
+ shape = RoundedCornerShape(12.dp),
+ elevation = 8.dp,
+ border = BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.2f))
+ ) {
+ Column {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ painterResource(MR.images.ic_search),
+ contentDescription = null,
+ Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF).size(22.dp),
+ tint = MaterialTheme.colors.secondary
+ )
+ SearchTextField(Modifier.weight(1f), alwaysVisible = false) { query.value = it }
+ }
+ if (found.value == null) {
+ EmojiCategoryTabs(sections, headerIndexes, gridState)
+ }
+ Divider()
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(EMOJI_CELL_SIZE),
+ state = gridState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(6.dp)
+ ) {
+ val foundEmojis = found.value
+ if (foundEmojis == null) {
+ sections.forEach { s ->
+ sectionHeader(s.category.title)
+ items(s.emojis) { EmojiCell(it.emoji, onEmoji) }
+ }
+ } else if (foundEmojis.isEmpty()) {
+ sectionHeader(MR.strings.no_emoji_found)
+ } else {
+ items(foundEmojis) { EmojiCell(it.emoji, onEmoji) }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmojiCategoryTabs(sections: List, headerIndexes: List, gridState: LazyGridState) {
+ val scope = rememberCoroutineScope()
+ val selected = remember {
+ derivedStateOf { headerIndexes.indexOfLast { it <= gridState.firstVisibleItemIndex } }
+ }
+ Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween) {
+ sections.forEachIndexed { i, s ->
+ EmojiTab(selected = selected.value == i, onClick = { scope.launch { gridState.scrollToItem(headerIndexes[i]) } }) {
+ Text(s.category.icon, fontFamily = EmojiFont, fontSize = with(LocalDensity.current) { EMOJI_TAB_ICON_SIZE.toSp() })
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmojiTab(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
+ Box(
+ Modifier
+ .size(EMOJI_TAB_SIZE)
+ .clip(RoundedCornerShape(8.dp))
+ .background(if (selected) MaterialTheme.colors.secondary.copy(alpha = 0.15f) else Color.Transparent)
+ .focusProperties { canFocus = false }
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center
+ ) {
+ content()
+ }
+}
+
+private fun LazyGridScope.sectionHeader(title: StringResource) {
+ item(span = { GridItemSpan(maxLineSpan) }) {
+ Text(
+ stringResource(title),
+ Modifier.padding(start = 4.dp, top = 8.dp, bottom = 4.dp),
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.secondary
+ )
+ }
+}
+
+@Composable
+private fun EmojiCell(emoji: String, onEmoji: (String) -> Unit) {
+ Box(
+ Modifier
+ .size(EMOJI_CELL_SIZE)
+ .clip(RoundedCornerShape(8.dp))
+ // not focusable, so that the search field keeps focus and typing after a click goes to search
+ .focusProperties { canFocus = false }
+ .clickable { onEmoji(emoji) },
+ contentAlignment = Alignment.Center
+ ) {
+ Text(emoji, fontFamily = EmojiFont, fontSize = with(LocalDensity.current) { EMOJI_SIZE.toSp() }, maxLines = 1)
+ }
+}
+
+@Composable
+private fun EmojiIconButton(icon: Painter, contentDescription: String, enabled: Boolean, onClick: () -> Unit) {
+ IconButton(onClick, enabled = enabled) {
+ Icon(
+ icon,
+ contentDescription = contentDescription,
+ tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
+ modifier = Modifier
+ .size(28.dp)
+ .clip(CircleShape)
+ )
+ }
+}
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt
index 98ffa7c8a4..dd76e86c81 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt
@@ -23,7 +23,7 @@ actual fun SimpleAndAnimatedImageView(
// still image. A full screen modal is shown beside the chat rather than in place of it, so this item keeps
// composing under one and would otherwise decode where nobody can see it.
val frame = if (smallView) imageBitmap
- else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() }
+ else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() || ModalManager.center.hasModalsOpen() }
ImageView(BitmapPainter(frame)) {
if (getLoadedFilePath(file) != null) {
ModalManager.fullscreen.showCustomModal(animated = false) { close ->
diff --git a/apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt b/apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt
new file mode 100644
index 0000000000..2a25c14c9f
--- /dev/null
+++ b/apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt
@@ -0,0 +1,1879 @@
+@Smileys & Emotion
+😀 grinning face
+😃 grinning face with big eyes
+😄 grinning face with smiling eyes
+😁 beaming face with smiling eyes
+😆 grinning squinting face
+😅 grinning face with sweat
+🤣 rolling on the floor laughing
+😂 face with tears of joy
+🙂 slightly smiling face
+🙃 upside-down face
+🫠 melting face
+😉 winking face
+😊 smiling face with smiling eyes
+😇 smiling face with halo
+🥰 smiling face with hearts
+😍 smiling face with heart-eyes
+🤩 star-struck
+😘 face blowing a kiss
+😗 kissing face
+☺️ smiling face
+😚 kissing face with closed eyes
+😙 kissing face with smiling eyes
+🥲 smiling face with tear
+😋 face savoring food
+😛 face with tongue
+😜 winking face with tongue
+🤪 zany face
+😝 squinting face with tongue
+🤑 money-mouth face
+🤗 smiling face with open hands
+🤭 face with hand over mouth
+🫢 face with open eyes and hand over mouth
+🫣 face with peeking eye
+🤫 shushing face
+🤔 thinking face
+🫡 saluting face
+🤐 zipper-mouth face
+🤨 face with raised eyebrow
+😐 neutral face
+😑 expressionless face
+😶 face without mouth
+🫥 dotted line face
+😶🌫️ face in clouds
+😏 smirking face
+😒 unamused face
+🙄 face with rolling eyes
+😬 grimacing face
+😮💨 face exhaling
+🤥 lying face
+🫨 shaking face
+😌 relieved face
+😔 pensive face
+😪 sleepy face
+🤤 drooling face
+😴 sleeping face
+😷 face with medical mask
+🤒 face with thermometer
+🤕 face with head-bandage
+🤢 nauseated face
+🤮 face vomiting
+🤧 sneezing face
+🥵 hot face
+🥶 cold face
+🥴 woozy face
+😵 face with crossed-out eyes
+😵💫 face with spiral eyes
+🤯 exploding head
+🤠 cowboy hat face
+🥳 partying face
+🥸 disguised face
+😎 smiling face with sunglasses
+🤓 nerd face
+🧐 face with monocle
+😕 confused face
+🫤 face with diagonal mouth
+😟 worried face
+🙁 slightly frowning face
+☹️ frowning face
+😮 face with open mouth
+😯 hushed face
+😲 astonished face
+😳 flushed face
+🥺 pleading face
+🥹 face holding back tears
+😦 frowning face with open mouth
+😧 anguished face
+😨 fearful face
+😰 anxious face with sweat
+😥 sad but relieved face
+😢 crying face
+😭 loudly crying face
+😱 face screaming in fear
+😖 confounded face
+😣 persevering face
+😞 disappointed face
+😓 downcast face with sweat
+😩 weary face
+😫 tired face
+🥱 yawning face
+😤 face with steam from nose
+😡 enraged face
+😠 angry face
+🤬 face with symbols on mouth
+😈 smiling face with horns
+👿 angry face with horns
+💀 skull
+☠️ skull and crossbones
+💩 pile of poo
+🤡 clown face
+👹 ogre
+👺 goblin
+👻 ghost
+👽 alien
+👾 alien monster
+🤖 robot
+😺 grinning cat
+😸 grinning cat with smiling eyes
+😹 cat with tears of joy
+😻 smiling cat with heart-eyes
+😼 cat with wry smile
+😽 kissing cat
+🙀 weary cat
+😿 crying cat
+😾 pouting cat
+🙈 see-no-evil monkey
+🙉 hear-no-evil monkey
+🙊 speak-no-evil monkey
+💌 love letter
+💘 heart with arrow
+💝 heart with ribbon
+💖 sparkling heart
+💗 growing heart
+💓 beating heart
+💞 revolving hearts
+💕 two hearts
+💟 heart decoration
+❣️ heart exclamation
+💔 broken heart
+❤️🔥 heart on fire
+❤️🩹 mending heart
+❤️ red heart
+🩷 pink heart
+🧡 orange heart
+💛 yellow heart
+💚 green heart
+💙 blue heart
+🩵 light blue heart
+💜 purple heart
+🤎 brown heart
+🖤 black heart
+🩶 grey heart
+🤍 white heart
+💋 kiss mark
+💯 hundred points
+💢 anger symbol
+💥 collision
+💫 dizzy
+💦 sweat droplets
+💨 dashing away
+🕳️ hole
+💬 speech balloon
+👁️🗨️ eye in speech bubble
+🗨️ left speech bubble
+🗯️ right anger bubble
+💭 thought balloon
+💤 ZZZ
+@People & Body
+👋 waving hand
+🤚 raised back of hand
+🖐️ hand with fingers splayed
+✋ raised hand
+🖖 vulcan salute
+🫱 rightwards hand
+🫲 leftwards hand
+🫳 palm down hand
+🫴 palm up hand
+🫷 leftwards pushing hand
+🫸 rightwards pushing hand
+👌 OK hand
+🤌 pinched fingers
+🤏 pinching hand
+✌️ victory hand
+🤞 crossed fingers
+🫰 hand with index finger and thumb crossed
+🤟 love-you gesture
+🤘 sign of the horns
+🤙 call me hand
+👈 backhand index pointing left
+👉 backhand index pointing right
+👆 backhand index pointing up
+🖕 middle finger
+👇 backhand index pointing down
+☝️ index pointing up
+🫵 index pointing at the viewer
+👍 thumbs up
+👎 thumbs down
+✊ raised fist
+👊 oncoming fist
+🤛 left-facing fist
+🤜 right-facing fist
+👏 clapping hands
+🙌 raising hands
+🫶 heart hands
+👐 open hands
+🤲 palms up together
+🤝 handshake
+🙏 folded hands
+✍️ writing hand
+💅 nail polish
+🤳 selfie
+💪 flexed biceps
+🦾 mechanical arm
+🦿 mechanical leg
+🦵 leg
+🦶 foot
+👂 ear
+🦻 ear with hearing aid
+👃 nose
+🧠 brain
+🫀 anatomical heart
+🫁 lungs
+🦷 tooth
+🦴 bone
+👀 eyes
+👁️ eye
+👅 tongue
+👄 mouth
+🫦 biting lip
+👶 baby
+🧒 child
+👦 boy
+👧 girl
+🧑 person
+👱 person: blond hair
+👨 man
+🧔 person: beard
+🧔♂️ man: beard
+🧔♀️ woman: beard
+👨🦰 man: red hair
+👨🦱 man: curly hair
+👨🦳 man: white hair
+👨🦲 man: bald
+👩 woman
+👩🦰 woman: red hair
+🧑🦰 person: red hair
+👩🦱 woman: curly hair
+🧑🦱 person: curly hair
+👩🦳 woman: white hair
+🧑🦳 person: white hair
+👩🦲 woman: bald
+🧑🦲 person: bald
+👱♀️ woman: blond hair
+👱♂️ man: blond hair
+🧓 older person
+👴 old man
+👵 old woman
+🙍 person frowning
+🙍♂️ man frowning
+🙍♀️ woman frowning
+🙎 person pouting
+🙎♂️ man pouting
+🙎♀️ woman pouting
+🙅 person gesturing NO
+🙅♂️ man gesturing NO
+🙅♀️ woman gesturing NO
+🙆 person gesturing OK
+🙆♂️ man gesturing OK
+🙆♀️ woman gesturing OK
+💁 person tipping hand
+💁♂️ man tipping hand
+💁♀️ woman tipping hand
+🙋 person raising hand
+🙋♂️ man raising hand
+🙋♀️ woman raising hand
+🧏 deaf person
+🧏♂️ deaf man
+🧏♀️ deaf woman
+🙇 person bowing
+🙇♂️ man bowing
+🙇♀️ woman bowing
+🤦 person facepalming
+🤦♂️ man facepalming
+🤦♀️ woman facepalming
+🤷 person shrugging
+🤷♂️ man shrugging
+🤷♀️ woman shrugging
+🧑⚕️ health worker
+👨⚕️ man health worker
+👩⚕️ woman health worker
+🧑🎓 student
+👨🎓 man student
+👩🎓 woman student
+🧑🏫 teacher
+👨🏫 man teacher
+👩🏫 woman teacher
+🧑⚖️ judge
+👨⚖️ man judge
+👩⚖️ woman judge
+🧑🌾 farmer
+👨🌾 man farmer
+👩🌾 woman farmer
+🧑🍳 cook
+👨🍳 man cook
+👩🍳 woman cook
+🧑🔧 mechanic
+👨🔧 man mechanic
+👩🔧 woman mechanic
+🧑🏭 factory worker
+👨🏭 man factory worker
+👩🏭 woman factory worker
+🧑💼 office worker
+👨💼 man office worker
+👩💼 woman office worker
+🧑🔬 scientist
+👨🔬 man scientist
+👩🔬 woman scientist
+🧑💻 technologist
+👨💻 man technologist
+👩💻 woman technologist
+🧑🎤 singer
+👨🎤 man singer
+👩🎤 woman singer
+🧑🎨 artist
+👨🎨 man artist
+👩🎨 woman artist
+🧑✈️ pilot
+👨✈️ man pilot
+👩✈️ woman pilot
+🧑🚀 astronaut
+👨🚀 man astronaut
+👩🚀 woman astronaut
+🧑🚒 firefighter
+👨🚒 man firefighter
+👩🚒 woman firefighter
+👮 police officer
+👮♂️ man police officer
+👮♀️ woman police officer
+🕵️ detective
+🕵️♂️ man detective
+🕵️♀️ woman detective
+💂 guard
+💂♂️ man guard
+💂♀️ woman guard
+🥷 ninja
+👷 construction worker
+👷♂️ man construction worker
+👷♀️ woman construction worker
+🫅 person with crown
+🤴 prince
+👸 princess
+👳 person wearing turban
+👳♂️ man wearing turban
+👳♀️ woman wearing turban
+👲 person with skullcap
+🧕 woman with headscarf
+🤵 person in tuxedo
+🤵♂️ man in tuxedo
+🤵♀️ woman in tuxedo
+👰 person with veil
+👰♂️ man with veil
+👰♀️ woman with veil
+🤰 pregnant woman
+🫃 pregnant man
+🫄 pregnant person
+🤱 breast-feeding
+👩🍼 woman feeding baby
+👨🍼 man feeding baby
+🧑🍼 person feeding baby
+👼 baby angel
+🎅 Santa Claus
+🤶 Mrs. Claus
+🧑🎄 mx claus
+🦸 superhero
+🦸♂️ man superhero
+🦸♀️ woman superhero
+🦹 supervillain
+🦹♂️ man supervillain
+🦹♀️ woman supervillain
+🧙 mage
+🧙♂️ man mage
+🧙♀️ woman mage
+🧚 fairy
+🧚♂️ man fairy
+🧚♀️ woman fairy
+🧛 vampire
+🧛♂️ man vampire
+🧛♀️ woman vampire
+🧜 merperson
+🧜♂️ merman
+🧜♀️ mermaid
+🧝 elf
+🧝♂️ man elf
+🧝♀️ woman elf
+🧞 genie
+🧞♂️ man genie
+🧞♀️ woman genie
+🧟 zombie
+🧟♂️ man zombie
+🧟♀️ woman zombie
+🧌 troll
+💆 person getting massage
+💆♂️ man getting massage
+💆♀️ woman getting massage
+💇 person getting haircut
+💇♂️ man getting haircut
+💇♀️ woman getting haircut
+🚶 person walking
+🚶♂️ man walking
+🚶♀️ woman walking
+🧍 person standing
+🧍♂️ man standing
+🧍♀️ woman standing
+🧎 person kneeling
+🧎♂️ man kneeling
+🧎♀️ woman kneeling
+🧑🦯 person with white cane
+👨🦯 man with white cane
+👩🦯 woman with white cane
+🧑🦼 person in motorized wheelchair
+👨🦼 man in motorized wheelchair
+👩🦼 woman in motorized wheelchair
+🧑🦽 person in manual wheelchair
+👨🦽 man in manual wheelchair
+👩🦽 woman in manual wheelchair
+🏃 person running
+🏃♂️ man running
+🏃♀️ woman running
+💃 woman dancing
+🕺 man dancing
+🕴️ person in suit levitating
+👯 people with bunny ears
+👯♂️ men with bunny ears
+👯♀️ women with bunny ears
+🧖 person in steamy room
+🧖♂️ man in steamy room
+🧖♀️ woman in steamy room
+🧗 person climbing
+🧗♂️ man climbing
+🧗♀️ woman climbing
+🤺 person fencing
+🏇 horse racing
+⛷️ skier
+🏂 snowboarder
+🏌️ person golfing
+🏌️♂️ man golfing
+🏌️♀️ woman golfing
+🏄 person surfing
+🏄♂️ man surfing
+🏄♀️ woman surfing
+🚣 person rowing boat
+🚣♂️ man rowing boat
+🚣♀️ woman rowing boat
+🏊 person swimming
+🏊♂️ man swimming
+🏊♀️ woman swimming
+⛹️ person bouncing ball
+⛹️♂️ man bouncing ball
+⛹️♀️ woman bouncing ball
+🏋️ person lifting weights
+🏋️♂️ man lifting weights
+🏋️♀️ woman lifting weights
+🚴 person biking
+🚴♂️ man biking
+🚴♀️ woman biking
+🚵 person mountain biking
+🚵♂️ man mountain biking
+🚵♀️ woman mountain biking
+🤸 person cartwheeling
+🤸♂️ man cartwheeling
+🤸♀️ woman cartwheeling
+🤼 people wrestling
+🤼♂️ men wrestling
+🤼♀️ women wrestling
+🤽 person playing water polo
+🤽♂️ man playing water polo
+🤽♀️ woman playing water polo
+🤾 person playing handball
+🤾♂️ man playing handball
+🤾♀️ woman playing handball
+🤹 person juggling
+🤹♂️ man juggling
+🤹♀️ woman juggling
+🧘 person in lotus position
+🧘♂️ man in lotus position
+🧘♀️ woman in lotus position
+🛀 person taking bath
+🛌 person in bed
+🧑🤝🧑 people holding hands
+👭 women holding hands
+👫 woman and man holding hands
+👬 men holding hands
+💏 kiss
+👩❤️💋👨 kiss: woman, man
+👨❤️💋👨 kiss: man, man
+👩❤️💋👩 kiss: woman, woman
+💑 couple with heart
+👩❤️👨 couple with heart: woman, man
+👨❤️👨 couple with heart: man, man
+👩❤️👩 couple with heart: woman, woman
+👪 family
+👨👩👦 family: man, woman, boy
+👨👩👧 family: man, woman, girl
+👨👩👧👦 family: man, woman, girl, boy
+👨👩👦👦 family: man, woman, boy, boy
+👨👩👧👧 family: man, woman, girl, girl
+👨👨👦 family: man, man, boy
+👨👨👧 family: man, man, girl
+👨👨👧👦 family: man, man, girl, boy
+👨👨👦👦 family: man, man, boy, boy
+👨👨👧👧 family: man, man, girl, girl
+👩👩👦 family: woman, woman, boy
+👩👩👧 family: woman, woman, girl
+👩👩👧👦 family: woman, woman, girl, boy
+👩👩👦👦 family: woman, woman, boy, boy
+👩👩👧👧 family: woman, woman, girl, girl
+👨👦 family: man, boy
+👨👦👦 family: man, boy, boy
+👨👧 family: man, girl
+👨👧👦 family: man, girl, boy
+👨👧👧 family: man, girl, girl
+👩👦 family: woman, boy
+👩👦👦 family: woman, boy, boy
+👩👧 family: woman, girl
+👩👧👦 family: woman, girl, boy
+👩👧👧 family: woman, girl, girl
+🗣️ speaking head
+👤 bust in silhouette
+👥 busts in silhouette
+🫂 people hugging
+👣 footprints
+@Animals & Nature
+🐵 monkey face
+🐒 monkey
+🦍 gorilla
+🦧 orangutan
+🐶 dog face
+🐕 dog
+🦮 guide dog
+🐕🦺 service dog
+🐩 poodle
+🐺 wolf
+🦊 fox
+🦝 raccoon
+🐱 cat face
+🐈 cat
+🐈⬛ black cat
+🦁 lion
+🐯 tiger face
+🐅 tiger
+🐆 leopard
+🐴 horse face
+🫎 moose
+🫏 donkey
+🐎 horse
+🦄 unicorn
+🦓 zebra
+🦌 deer
+🦬 bison
+🐮 cow face
+🐂 ox
+🐃 water buffalo
+🐄 cow
+🐷 pig face
+🐖 pig
+🐗 boar
+🐽 pig nose
+🐏 ram
+🐑 ewe
+🐐 goat
+🐪 camel
+🐫 two-hump camel
+🦙 llama
+🦒 giraffe
+🐘 elephant
+🦣 mammoth
+🦏 rhinoceros
+🦛 hippopotamus
+🐭 mouse face
+🐁 mouse
+🐀 rat
+🐹 hamster
+🐰 rabbit face
+🐇 rabbit
+🐿️ chipmunk
+🦫 beaver
+🦔 hedgehog
+🦇 bat
+🐻 bear
+🐻❄️ polar bear
+🐨 koala
+🐼 panda
+🦥 sloth
+🦦 otter
+🦨 skunk
+🦘 kangaroo
+🦡 badger
+🐾 paw prints
+🦃 turkey
+🐔 chicken
+🐓 rooster
+🐣 hatching chick
+🐤 baby chick
+🐥 front-facing baby chick
+🐦 bird
+🐧 penguin
+🕊️ dove
+🦅 eagle
+🦆 duck
+🦢 swan
+🦉 owl
+🦤 dodo
+🪶 feather
+🦩 flamingo
+🦚 peacock
+🦜 parrot
+🪽 wing
+🐦⬛ black bird
+🪿 goose
+🐸 frog
+🐊 crocodile
+🐢 turtle
+🦎 lizard
+🐍 snake
+🐲 dragon face
+🐉 dragon
+🦕 sauropod
+🦖 T-Rex
+🐳 spouting whale
+🐋 whale
+🐬 dolphin
+🦭 seal
+🐟 fish
+🐠 tropical fish
+🐡 blowfish
+🦈 shark
+🐙 octopus
+🐚 spiral shell
+🪸 coral
+🪼 jellyfish
+🐌 snail
+🦋 butterfly
+🐛 bug
+🐜 ant
+🐝 honeybee
+🪲 beetle
+🐞 lady beetle
+🦗 cricket
+🪳 cockroach
+🕷️ spider
+🕸️ spider web
+🦂 scorpion
+🦟 mosquito
+🪰 fly
+🪱 worm
+🦠 microbe
+💐 bouquet
+🌸 cherry blossom
+💮 white flower
+🪷 lotus
+🏵️ rosette
+🌹 rose
+🥀 wilted flower
+🌺 hibiscus
+🌻 sunflower
+🌼 blossom
+🌷 tulip
+🪻 hyacinth
+🌱 seedling
+🪴 potted plant
+🌲 evergreen tree
+🌳 deciduous tree
+🌴 palm tree
+🌵 cactus
+🌾 sheaf of rice
+🌿 herb
+☘️ shamrock
+🍀 four leaf clover
+🍁 maple leaf
+🍂 fallen leaf
+🍃 leaf fluttering in wind
+🪹 empty nest
+🪺 nest with eggs
+🍄 mushroom
+@Food & Drink
+🍇 grapes
+🍈 melon
+🍉 watermelon
+🍊 tangerine
+🍋 lemon
+🍌 banana
+🍍 pineapple
+🥭 mango
+🍎 red apple
+🍏 green apple
+🍐 pear
+🍑 peach
+🍒 cherries
+🍓 strawberry
+🫐 blueberries
+🥝 kiwi fruit
+🍅 tomato
+🫒 olive
+🥥 coconut
+🥑 avocado
+🍆 eggplant
+🥔 potato
+🥕 carrot
+🌽 ear of corn
+🌶️ hot pepper
+🫑 bell pepper
+🥒 cucumber
+🥬 leafy green
+🥦 broccoli
+🧄 garlic
+🧅 onion
+🥜 peanuts
+🫘 beans
+🌰 chestnut
+🫚 ginger root
+🫛 pea pod
+🍞 bread
+🥐 croissant
+🥖 baguette bread
+🫓 flatbread
+🥨 pretzel
+🥯 bagel
+🥞 pancakes
+🧇 waffle
+🧀 cheese wedge
+🍖 meat on bone
+🍗 poultry leg
+🥩 cut of meat
+🥓 bacon
+🍔 hamburger
+🍟 french fries
+🍕 pizza
+🌭 hot dog
+🥪 sandwich
+🌮 taco
+🌯 burrito
+🫔 tamale
+🥙 stuffed flatbread
+🧆 falafel
+🥚 egg
+🍳 cooking
+🥘 shallow pan of food
+🍲 pot of food
+🫕 fondue
+🥣 bowl with spoon
+🥗 green salad
+🍿 popcorn
+🧈 butter
+🧂 salt
+🥫 canned food
+🍱 bento box
+🍘 rice cracker
+🍙 rice ball
+🍚 cooked rice
+🍛 curry rice
+🍜 steaming bowl
+🍝 spaghetti
+🍠 roasted sweet potato
+🍢 oden
+🍣 sushi
+🍤 fried shrimp
+🍥 fish cake with swirl
+🥮 moon cake
+🍡 dango
+🥟 dumpling
+🥠 fortune cookie
+🥡 takeout box
+🦀 crab
+🦞 lobster
+🦐 shrimp
+🦑 squid
+🦪 oyster
+🍦 soft ice cream
+🍧 shaved ice
+🍨 ice cream
+🍩 doughnut
+🍪 cookie
+🎂 birthday cake
+🍰 shortcake
+🧁 cupcake
+🥧 pie
+🍫 chocolate bar
+🍬 candy
+🍭 lollipop
+🍮 custard
+🍯 honey pot
+🍼 baby bottle
+🥛 glass of milk
+☕ hot beverage
+🫖 teapot
+🍵 teacup without handle
+🍶 sake
+🍾 bottle with popping cork
+🍷 wine glass
+🍸 cocktail glass
+🍹 tropical drink
+🍺 beer mug
+🍻 clinking beer mugs
+🥂 clinking glasses
+🥃 tumbler glass
+🫗 pouring liquid
+🥤 cup with straw
+🧋 bubble tea
+🧃 beverage box
+🧉 mate
+🧊 ice
+🥢 chopsticks
+🍽️ fork and knife with plate
+🍴 fork and knife
+🥄 spoon
+🔪 kitchen knife
+🫙 jar
+🏺 amphora
+@Travel & Places
+🌍 globe showing Europe-Africa
+🌎 globe showing Americas
+🌏 globe showing Asia-Australia
+🌐 globe with meridians
+🗺️ world map
+🗾 map of Japan
+🧭 compass
+🏔️ snow-capped mountain
+⛰️ mountain
+🌋 volcano
+🗻 mount fuji
+🏕️ camping
+🏖️ beach with umbrella
+🏜️ desert
+🏝️ desert island
+🏞️ national park
+🏟️ stadium
+🏛️ classical building
+🏗️ building construction
+🧱 brick
+🪨 rock
+🪵 wood
+🛖 hut
+🏘️ houses
+🏚️ derelict house
+🏠 house
+🏡 house with garden
+🏢 office building
+🏣 Japanese post office
+🏤 post office
+🏥 hospital
+🏦 bank
+🏨 hotel
+🏩 love hotel
+🏪 convenience store
+🏫 school
+🏬 department store
+🏭 factory
+🏯 Japanese castle
+🏰 castle
+💒 wedding
+🗼 Tokyo tower
+🗽 Statue of Liberty
+⛪ church
+🕌 mosque
+🛕 hindu temple
+🕍 synagogue
+⛩️ shinto shrine
+🕋 kaaba
+⛲ fountain
+⛺ tent
+🌁 foggy
+🌃 night with stars
+🏙️ cityscape
+🌄 sunrise over mountains
+🌅 sunrise
+🌆 cityscape at dusk
+🌇 sunset
+🌉 bridge at night
+♨️ hot springs
+🎠 carousel horse
+🛝 playground slide
+🎡 ferris wheel
+🎢 roller coaster
+💈 barber pole
+🎪 circus tent
+🚂 locomotive
+🚃 railway car
+🚄 high-speed train
+🚅 bullet train
+🚆 train
+🚇 metro
+🚈 light rail
+🚉 station
+🚊 tram
+🚝 monorail
+🚞 mountain railway
+🚋 tram car
+🚌 bus
+🚍 oncoming bus
+🚎 trolleybus
+🚐 minibus
+🚑 ambulance
+🚒 fire engine
+🚓 police car
+🚔 oncoming police car
+🚕 taxi
+🚖 oncoming taxi
+🚗 automobile
+🚘 oncoming automobile
+🚙 sport utility vehicle
+🛻 pickup truck
+🚚 delivery truck
+🚛 articulated lorry
+🚜 tractor
+🏎️ racing car
+🏍️ motorcycle
+🛵 motor scooter
+🦽 manual wheelchair
+🦼 motorized wheelchair
+🛺 auto rickshaw
+🚲 bicycle
+🛴 kick scooter
+🛹 skateboard
+🛼 roller skate
+🚏 bus stop
+🛣️ motorway
+🛤️ railway track
+🛢️ oil drum
+⛽ fuel pump
+🛞 wheel
+🚨 police car light
+🚥 horizontal traffic light
+🚦 vertical traffic light
+🛑 stop sign
+🚧 construction
+⚓ anchor
+🛟 ring buoy
+⛵ sailboat
+🛶 canoe
+🚤 speedboat
+🛳️ passenger ship
+⛴️ ferry
+🛥️ motor boat
+🚢 ship
+✈️ airplane
+🛩️ small airplane
+🛫 airplane departure
+🛬 airplane arrival
+🪂 parachute
+💺 seat
+🚁 helicopter
+🚟 suspension railway
+🚠 mountain cableway
+🚡 aerial tramway
+🛰️ satellite
+🚀 rocket
+🛸 flying saucer
+🛎️ bellhop bell
+🧳 luggage
+⌛ hourglass done
+⏳ hourglass not done
+⌚ watch
+⏰ alarm clock
+⏱️ stopwatch
+⏲️ timer clock
+🕰️ mantelpiece clock
+🕛 twelve o’clock
+🕧 twelve-thirty
+🕐 one o’clock
+🕜 one-thirty
+🕑 two o’clock
+🕝 two-thirty
+🕒 three o’clock
+🕞 three-thirty
+🕓 four o’clock
+🕟 four-thirty
+🕔 five o’clock
+🕠 five-thirty
+🕕 six o’clock
+🕡 six-thirty
+🕖 seven o’clock
+🕢 seven-thirty
+🕗 eight o’clock
+🕣 eight-thirty
+🕘 nine o’clock
+🕤 nine-thirty
+🕙 ten o’clock
+🕥 ten-thirty
+🕚 eleven o’clock
+🕦 eleven-thirty
+🌑 new moon
+🌒 waxing crescent moon
+🌓 first quarter moon
+🌔 waxing gibbous moon
+🌕 full moon
+🌖 waning gibbous moon
+🌗 last quarter moon
+🌘 waning crescent moon
+🌙 crescent moon
+🌚 new moon face
+🌛 first quarter moon face
+🌜 last quarter moon face
+🌡️ thermometer
+☀️ sun
+🌝 full moon face
+🌞 sun with face
+🪐 ringed planet
+⭐ star
+🌟 glowing star
+🌠 shooting star
+🌌 milky way
+☁️ cloud
+⛅ sun behind cloud
+⛈️ cloud with lightning and rain
+🌤️ sun behind small cloud
+🌥️ sun behind large cloud
+🌦️ sun behind rain cloud
+🌧️ cloud with rain
+🌨️ cloud with snow
+🌩️ cloud with lightning
+🌪️ tornado
+🌫️ fog
+🌬️ wind face
+🌀 cyclone
+🌈 rainbow
+🌂 closed umbrella
+☂️ umbrella
+☔ umbrella with rain drops
+⛱️ umbrella on ground
+⚡ high voltage
+❄️ snowflake
+☃️ snowman
+⛄ snowman without snow
+☄️ comet
+🔥 fire
+💧 droplet
+🌊 water wave
+@Activities
+🎃 jack-o-lantern
+🎄 Christmas tree
+🎆 fireworks
+🎇 sparkler
+🧨 firecracker
+✨ sparkles
+🎈 balloon
+🎉 party popper
+🎊 confetti ball
+🎋 tanabata tree
+🎍 pine decoration
+🎎 Japanese dolls
+🎏 carp streamer
+🎐 wind chime
+🎑 moon viewing ceremony
+🧧 red envelope
+🎀 ribbon
+🎁 wrapped gift
+🎗️ reminder ribbon
+🎟️ admission tickets
+🎫 ticket
+🎖️ military medal
+🏆 trophy
+🏅 sports medal
+🥇 1st place medal
+🥈 2nd place medal
+🥉 3rd place medal
+⚽ soccer ball
+⚾ baseball
+🥎 softball
+🏀 basketball
+🏐 volleyball
+🏈 american football
+🏉 rugby football
+🎾 tennis
+🥏 flying disc
+🎳 bowling
+🏏 cricket game
+🏑 field hockey
+🏒 ice hockey
+🥍 lacrosse
+🏓 ping pong
+🏸 badminton
+🥊 boxing glove
+🥋 martial arts uniform
+🥅 goal net
+⛳ flag in hole
+⛸️ ice skate
+🎣 fishing pole
+🤿 diving mask
+🎽 running shirt
+🎿 skis
+🛷 sled
+🥌 curling stone
+🎯 bullseye
+🪀 yo-yo
+🪁 kite
+🔫 water pistol
+🎱 pool 8 ball
+🔮 crystal ball
+🪄 magic wand
+🎮 video game
+🕹️ joystick
+🎰 slot machine
+🎲 game die
+🧩 puzzle piece
+🧸 teddy bear
+🪅 piñata
+🪩 mirror ball
+🪆 nesting dolls
+♠️ spade suit
+♥️ heart suit
+♦️ diamond suit
+♣️ club suit
+♟️ chess pawn
+🃏 joker
+🀄 mahjong red dragon
+🎴 flower playing cards
+🎭 performing arts
+🖼️ framed picture
+🎨 artist palette
+🧵 thread
+🪡 sewing needle
+🧶 yarn
+🪢 knot
+@Objects
+👓 glasses
+🕶️ sunglasses
+🥽 goggles
+🥼 lab coat
+🦺 safety vest
+👔 necktie
+👕 t-shirt
+👖 jeans
+🧣 scarf
+🧤 gloves
+🧥 coat
+🧦 socks
+👗 dress
+👘 kimono
+🥻 sari
+🩱 one-piece swimsuit
+🩲 briefs
+🩳 shorts
+👙 bikini
+👚 woman’s clothes
+🪭 folding hand fan
+👛 purse
+👜 handbag
+👝 clutch bag
+🛍️ shopping bags
+🎒 backpack
+🩴 thong sandal
+👞 man’s shoe
+👟 running shoe
+🥾 hiking boot
+🥿 flat shoe
+👠 high-heeled shoe
+👡 woman’s sandal
+🩰 ballet shoes
+👢 woman’s boot
+🪮 hair pick
+👑 crown
+👒 woman’s hat
+🎩 top hat
+🎓 graduation cap
+🧢 billed cap
+🪖 military helmet
+⛑️ rescue worker’s helmet
+📿 prayer beads
+💄 lipstick
+💍 ring
+💎 gem stone
+🔇 muted speaker
+🔈 speaker low volume
+🔉 speaker medium volume
+🔊 speaker high volume
+📢 loudspeaker
+📣 megaphone
+📯 postal horn
+🔔 bell
+🔕 bell with slash
+🎼 musical score
+🎵 musical note
+🎶 musical notes
+🎙️ studio microphone
+🎚️ level slider
+🎛️ control knobs
+🎤 microphone
+🎧 headphone
+📻 radio
+🎷 saxophone
+🪗 accordion
+🎸 guitar
+🎹 musical keyboard
+🎺 trumpet
+🎻 violin
+🪕 banjo
+🥁 drum
+🪘 long drum
+🪇 maracas
+🪈 flute
+📱 mobile phone
+📲 mobile phone with arrow
+☎️ telephone
+📞 telephone receiver
+📟 pager
+📠 fax machine
+🔋 battery
+🪫 low battery
+🔌 electric plug
+💻 laptop
+🖥️ desktop computer
+🖨️ printer
+⌨️ keyboard
+🖱️ computer mouse
+🖲️ trackball
+💽 computer disk
+💾 floppy disk
+💿 optical disk
+📀 dvd
+🧮 abacus
+🎥 movie camera
+🎞️ film frames
+📽️ film projector
+🎬 clapper board
+📺 television
+📷 camera
+📸 camera with flash
+📹 video camera
+📼 videocassette
+🔍 magnifying glass tilted left
+🔎 magnifying glass tilted right
+🕯️ candle
+💡 light bulb
+🔦 flashlight
+🏮 red paper lantern
+🪔 diya lamp
+📔 notebook with decorative cover
+📕 closed book
+📖 open book
+📗 green book
+📘 blue book
+📙 orange book
+📚 books
+📓 notebook
+📒 ledger
+📃 page with curl
+📜 scroll
+📄 page facing up
+📰 newspaper
+🗞️ rolled-up newspaper
+📑 bookmark tabs
+🔖 bookmark
+🏷️ label
+💰 money bag
+🪙 coin
+💴 yen banknote
+💵 dollar banknote
+💶 euro banknote
+💷 pound banknote
+💸 money with wings
+💳 credit card
+🧾 receipt
+💹 chart increasing with yen
+✉️ envelope
+📧 e-mail
+📨 incoming envelope
+📩 envelope with arrow
+📤 outbox tray
+📥 inbox tray
+📦 package
+📫 closed mailbox with raised flag
+📪 closed mailbox with lowered flag
+📬 open mailbox with raised flag
+📭 open mailbox with lowered flag
+📮 postbox
+🗳️ ballot box with ballot
+✏️ pencil
+✒️ black nib
+🖋️ fountain pen
+🖊️ pen
+🖌️ paintbrush
+🖍️ crayon
+📝 memo
+💼 briefcase
+📁 file folder
+📂 open file folder
+🗂️ card index dividers
+📅 calendar
+📆 tear-off calendar
+🗒️ spiral notepad
+🗓️ spiral calendar
+📇 card index
+📈 chart increasing
+📉 chart decreasing
+📊 bar chart
+📋 clipboard
+📌 pushpin
+📍 round pushpin
+📎 paperclip
+🖇️ linked paperclips
+📏 straight ruler
+📐 triangular ruler
+✂️ scissors
+🗃️ card file box
+🗄️ file cabinet
+🗑️ wastebasket
+🔒 locked
+🔓 unlocked
+🔏 locked with pen
+🔐 locked with key
+🔑 key
+🗝️ old key
+🔨 hammer
+🪓 axe
+⛏️ pick
+⚒️ hammer and pick
+🛠️ hammer and wrench
+🗡️ dagger
+⚔️ crossed swords
+💣 bomb
+🪃 boomerang
+🏹 bow and arrow
+🛡️ shield
+🪚 carpentry saw
+🔧 wrench
+🪛 screwdriver
+🔩 nut and bolt
+⚙️ gear
+🗜️ clamp
+⚖️ balance scale
+🦯 white cane
+🔗 link
+⛓️ chains
+🪝 hook
+🧰 toolbox
+🧲 magnet
+🪜 ladder
+⚗️ alembic
+🧪 test tube
+🧫 petri dish
+🧬 dna
+🔬 microscope
+🔭 telescope
+📡 satellite antenna
+💉 syringe
+🩸 drop of blood
+💊 pill
+🩹 adhesive bandage
+🩼 crutch
+🩺 stethoscope
+🩻 x-ray
+🚪 door
+🛗 elevator
+🪞 mirror
+🪟 window
+🛏️ bed
+🛋️ couch and lamp
+🪑 chair
+🚽 toilet
+🪠 plunger
+🚿 shower
+🛁 bathtub
+🪤 mouse trap
+🪒 razor
+🧴 lotion bottle
+🧷 safety pin
+🧹 broom
+🧺 basket
+🧻 roll of paper
+🪣 bucket
+🧼 soap
+🫧 bubbles
+🪥 toothbrush
+🧽 sponge
+🧯 fire extinguisher
+🛒 shopping cart
+🚬 cigarette
+⚰️ coffin
+🪦 headstone
+⚱️ funeral urn
+🧿 nazar amulet
+🪬 hamsa
+🗿 moai
+🪧 placard
+🪪 identification card
+@Symbols
+🏧 ATM sign
+🚮 litter in bin sign
+🚰 potable water
+♿ wheelchair symbol
+🚹 men’s room
+🚺 women’s room
+🚻 restroom
+🚼 baby symbol
+🚾 water closet
+🛂 passport control
+🛃 customs
+🛄 baggage claim
+🛅 left luggage
+⚠️ warning
+🚸 children crossing
+⛔ no entry
+🚫 prohibited
+🚳 no bicycles
+🚭 no smoking
+🚯 no littering
+🚱 non-potable water
+🚷 no pedestrians
+📵 no mobile phones
+🔞 no one under eighteen
+☢️ radioactive
+☣️ biohazard
+⬆️ up arrow
+↗️ up-right arrow
+➡️ right arrow
+↘️ down-right arrow
+⬇️ down arrow
+↙️ down-left arrow
+⬅️ left arrow
+↖️ up-left arrow
+↕️ up-down arrow
+↔️ left-right arrow
+↩️ right arrow curving left
+↪️ left arrow curving right
+⤴️ right arrow curving up
+⤵️ right arrow curving down
+🔃 clockwise vertical arrows
+🔄 counterclockwise arrows button
+🔙 BACK arrow
+🔚 END arrow
+🔛 ON! arrow
+🔜 SOON arrow
+🔝 TOP arrow
+🛐 place of worship
+⚛️ atom symbol
+🕉️ om
+✡️ star of David
+☸️ wheel of dharma
+☯️ yin yang
+✝️ latin cross
+☦️ orthodox cross
+☪️ star and crescent
+☮️ peace symbol
+🕎 menorah
+🔯 dotted six-pointed star
+🪯 khanda
+♈ Aries
+♉ Taurus
+♊ Gemini
+♋ Cancer
+♌ Leo
+♍ Virgo
+♎ Libra
+♏ Scorpio
+♐ Sagittarius
+♑ Capricorn
+♒ Aquarius
+♓ Pisces
+⛎ Ophiuchus
+🔀 shuffle tracks button
+🔁 repeat button
+🔂 repeat single button
+▶️ play button
+⏩ fast-forward button
+⏭️ next track button
+⏯️ play or pause button
+◀️ reverse button
+⏪ fast reverse button
+⏮️ last track button
+🔼 upwards button
+⏫ fast up button
+🔽 downwards button
+⏬ fast down button
+⏸️ pause button
+⏹️ stop button
+⏺️ record button
+⏏️ eject button
+🎦 cinema
+🔅 dim button
+🔆 bright button
+📶 antenna bars
+🛜 wireless
+📳 vibration mode
+📴 mobile phone off
+♀️ female sign
+♂️ male sign
+⚧️ transgender symbol
+✖️ multiply
+➕ plus
+➖ minus
+➗ divide
+🟰 heavy equals sign
+♾️ infinity
+‼️ double exclamation mark
+⁉️ exclamation question mark
+❓ red question mark
+❔ white question mark
+❕ white exclamation mark
+❗ red exclamation mark
+〰️ wavy dash
+💱 currency exchange
+💲 heavy dollar sign
+⚕️ medical symbol
+♻️ recycling symbol
+⚜️ fleur-de-lis
+🔱 trident emblem
+📛 name badge
+🔰 Japanese symbol for beginner
+⭕ hollow red circle
+✅ check mark button
+☑️ check box with check
+✔️ check mark
+❌ cross mark
+❎ cross mark button
+➰ curly loop
+➿ double curly loop
+〽️ part alternation mark
+✳️ eight-spoked asterisk
+✴️ eight-pointed star
+❇️ sparkle
+©️ copyright
+®️ registered
+™️ trade mark
+#️⃣ keycap: #
+*️⃣ keycap: *
+0️⃣ keycap: 0
+1️⃣ keycap: 1
+2️⃣ keycap: 2
+3️⃣ keycap: 3
+4️⃣ keycap: 4
+5️⃣ keycap: 5
+6️⃣ keycap: 6
+7️⃣ keycap: 7
+8️⃣ keycap: 8
+9️⃣ keycap: 9
+🔟 keycap: 10
+🔠 input latin uppercase
+🔡 input latin lowercase
+🔢 input numbers
+🔣 input symbols
+🔤 input latin letters
+🅰️ A button (blood type)
+🆎 AB button (blood type)
+🅱️ B button (blood type)
+🆑 CL button
+🆒 COOL button
+🆓 FREE button
+ℹ️ information
+🆔 ID button
+Ⓜ️ circled M
+🆕 NEW button
+🆖 NG button
+🅾️ O button (blood type)
+🆗 OK button
+🅿️ P button
+🆘 SOS button
+🆙 UP! button
+🆚 VS button
+🈁 Japanese “here” button
+🈂️ Japanese “service charge” button
+🈷️ Japanese “monthly amount” button
+🈶 Japanese “not free of charge” button
+🈯 Japanese “reserved” button
+🉐 Japanese “bargain” button
+🈹 Japanese “discount” button
+🈚 Japanese “free of charge” button
+🈲 Japanese “prohibited” button
+🉑 Japanese “acceptable” button
+🈸 Japanese “application” button
+🈴 Japanese “passing grade” button
+🈳 Japanese “vacancy” button
+㊗️ Japanese “congratulations” button
+㊙️ Japanese “secret” button
+🈺 Japanese “open for business” button
+🈵 Japanese “no vacancy” button
+🔴 red circle
+🟠 orange circle
+🟡 yellow circle
+🟢 green circle
+🔵 blue circle
+🟣 purple circle
+🟤 brown circle
+⚫ black circle
+⚪ white circle
+🟥 red square
+🟧 orange square
+🟨 yellow square
+🟩 green square
+🟦 blue square
+🟪 purple square
+🟫 brown square
+⬛ black large square
+⬜ white large square
+◼️ black medium square
+◻️ white medium square
+◾ black medium-small square
+◽ white medium-small square
+▪️ black small square
+▫️ white small square
+🔶 large orange diamond
+🔷 large blue diamond
+🔸 small orange diamond
+🔹 small blue diamond
+🔺 red triangle pointed up
+🔻 red triangle pointed down
+💠 diamond with a dot
+🔘 radio button
+🔳 white square button
+🔲 black square button
+@Flags
+🏁 chequered flag
+🚩 triangular flag
+🎌 crossed flags
+🏴 black flag
+🏳️ white flag
+🏳️🌈 rainbow flag
+🏳️⚧️ transgender flag
+🏴☠️ pirate flag
+🇦🇨 flag: Ascension Island
+🇦🇩 flag: Andorra
+🇦🇪 flag: United Arab Emirates
+🇦🇫 flag: Afghanistan
+🇦🇬 flag: Antigua & Barbuda
+🇦🇮 flag: Anguilla
+🇦🇱 flag: Albania
+🇦🇲 flag: Armenia
+🇦🇴 flag: Angola
+🇦🇶 flag: Antarctica
+🇦🇷 flag: Argentina
+🇦🇸 flag: American Samoa
+🇦🇹 flag: Austria
+🇦🇺 flag: Australia
+🇦🇼 flag: Aruba
+🇦🇽 flag: Åland Islands
+🇦🇿 flag: Azerbaijan
+🇧🇦 flag: Bosnia & Herzegovina
+🇧🇧 flag: Barbados
+🇧🇩 flag: Bangladesh
+🇧🇪 flag: Belgium
+🇧🇫 flag: Burkina Faso
+🇧🇬 flag: Bulgaria
+🇧🇭 flag: Bahrain
+🇧🇮 flag: Burundi
+🇧🇯 flag: Benin
+🇧🇱 flag: St. Barthélemy
+🇧🇲 flag: Bermuda
+🇧🇳 flag: Brunei
+🇧🇴 flag: Bolivia
+🇧🇶 flag: Caribbean Netherlands
+🇧🇷 flag: Brazil
+🇧🇸 flag: Bahamas
+🇧🇹 flag: Bhutan
+🇧🇻 flag: Bouvet Island
+🇧🇼 flag: Botswana
+🇧🇾 flag: Belarus
+🇧🇿 flag: Belize
+🇨🇦 flag: Canada
+🇨🇨 flag: Cocos (Keeling) Islands
+🇨🇩 flag: Congo - Kinshasa
+🇨🇫 flag: Central African Republic
+🇨🇬 flag: Congo - Brazzaville
+🇨🇭 flag: Switzerland
+🇨🇮 flag: Côte d’Ivoire
+🇨🇰 flag: Cook Islands
+🇨🇱 flag: Chile
+🇨🇲 flag: Cameroon
+🇨🇳 flag: China
+🇨🇴 flag: Colombia
+🇨🇵 flag: Clipperton Island
+🇨🇷 flag: Costa Rica
+🇨🇺 flag: Cuba
+🇨🇻 flag: Cape Verde
+🇨🇼 flag: Curaçao
+🇨🇽 flag: Christmas Island
+🇨🇾 flag: Cyprus
+🇨🇿 flag: Czechia
+🇩🇪 flag: Germany
+🇩🇬 flag: Diego Garcia
+🇩🇯 flag: Djibouti
+🇩🇰 flag: Denmark
+🇩🇲 flag: Dominica
+🇩🇴 flag: Dominican Republic
+🇩🇿 flag: Algeria
+🇪🇦 flag: Ceuta & Melilla
+🇪🇨 flag: Ecuador
+🇪🇪 flag: Estonia
+🇪🇬 flag: Egypt
+🇪🇭 flag: Western Sahara
+🇪🇷 flag: Eritrea
+🇪🇸 flag: Spain
+🇪🇹 flag: Ethiopia
+🇪🇺 flag: European Union
+🇫🇮 flag: Finland
+🇫🇯 flag: Fiji
+🇫🇰 flag: Falkland Islands
+🇫🇲 flag: Micronesia
+🇫🇴 flag: Faroe Islands
+🇫🇷 flag: France
+🇬🇦 flag: Gabon
+🇬🇧 flag: United Kingdom
+🇬🇩 flag: Grenada
+🇬🇪 flag: Georgia
+🇬🇫 flag: French Guiana
+🇬🇬 flag: Guernsey
+🇬🇭 flag: Ghana
+🇬🇮 flag: Gibraltar
+🇬🇱 flag: Greenland
+🇬🇲 flag: Gambia
+🇬🇳 flag: Guinea
+🇬🇵 flag: Guadeloupe
+🇬🇶 flag: Equatorial Guinea
+🇬🇷 flag: Greece
+🇬🇸 flag: South Georgia & South Sandwich Islands
+🇬🇹 flag: Guatemala
+🇬🇺 flag: Guam
+🇬🇼 flag: Guinea-Bissau
+🇬🇾 flag: Guyana
+🇭🇰 flag: Hong Kong SAR China
+🇭🇲 flag: Heard & McDonald Islands
+🇭🇳 flag: Honduras
+🇭🇷 flag: Croatia
+🇭🇹 flag: Haiti
+🇭🇺 flag: Hungary
+🇮🇨 flag: Canary Islands
+🇮🇩 flag: Indonesia
+🇮🇪 flag: Ireland
+🇮🇱 flag: Israel
+🇮🇲 flag: Isle of Man
+🇮🇳 flag: India
+🇮🇴 flag: British Indian Ocean Territory
+🇮🇶 flag: Iraq
+🇮🇷 flag: Iran
+🇮🇸 flag: Iceland
+🇮🇹 flag: Italy
+🇯🇪 flag: Jersey
+🇯🇲 flag: Jamaica
+🇯🇴 flag: Jordan
+🇯🇵 flag: Japan
+🇰🇪 flag: Kenya
+🇰🇬 flag: Kyrgyzstan
+🇰🇭 flag: Cambodia
+🇰🇮 flag: Kiribati
+🇰🇲 flag: Comoros
+🇰🇳 flag: St. Kitts & Nevis
+🇰🇵 flag: North Korea
+🇰🇷 flag: South Korea
+🇰🇼 flag: Kuwait
+🇰🇾 flag: Cayman Islands
+🇰🇿 flag: Kazakhstan
+🇱🇦 flag: Laos
+🇱🇧 flag: Lebanon
+🇱🇨 flag: St. Lucia
+🇱🇮 flag: Liechtenstein
+🇱🇰 flag: Sri Lanka
+🇱🇷 flag: Liberia
+🇱🇸 flag: Lesotho
+🇱🇹 flag: Lithuania
+🇱🇺 flag: Luxembourg
+🇱🇻 flag: Latvia
+🇱🇾 flag: Libya
+🇲🇦 flag: Morocco
+🇲🇨 flag: Monaco
+🇲🇩 flag: Moldova
+🇲🇪 flag: Montenegro
+🇲🇫 flag: St. Martin
+🇲🇬 flag: Madagascar
+🇲🇭 flag: Marshall Islands
+🇲🇰 flag: North Macedonia
+🇲🇱 flag: Mali
+🇲🇲 flag: Myanmar (Burma)
+🇲🇳 flag: Mongolia
+🇲🇴 flag: Macao SAR China
+🇲🇵 flag: Northern Mariana Islands
+🇲🇶 flag: Martinique
+🇲🇷 flag: Mauritania
+🇲🇸 flag: Montserrat
+🇲🇹 flag: Malta
+🇲🇺 flag: Mauritius
+🇲🇻 flag: Maldives
+🇲🇼 flag: Malawi
+🇲🇽 flag: Mexico
+🇲🇾 flag: Malaysia
+🇲🇿 flag: Mozambique
+🇳🇦 flag: Namibia
+🇳🇨 flag: New Caledonia
+🇳🇪 flag: Niger
+🇳🇫 flag: Norfolk Island
+🇳🇬 flag: Nigeria
+🇳🇮 flag: Nicaragua
+🇳🇱 flag: Netherlands
+🇳🇴 flag: Norway
+🇳🇵 flag: Nepal
+🇳🇷 flag: Nauru
+🇳🇺 flag: Niue
+🇳🇿 flag: New Zealand
+🇴🇲 flag: Oman
+🇵🇦 flag: Panama
+🇵🇪 flag: Peru
+🇵🇫 flag: French Polynesia
+🇵🇬 flag: Papua New Guinea
+🇵🇭 flag: Philippines
+🇵🇰 flag: Pakistan
+🇵🇱 flag: Poland
+🇵🇲 flag: St. Pierre & Miquelon
+🇵🇳 flag: Pitcairn Islands
+🇵🇷 flag: Puerto Rico
+🇵🇸 flag: Palestinian Territories
+🇵🇹 flag: Portugal
+🇵🇼 flag: Palau
+🇵🇾 flag: Paraguay
+🇶🇦 flag: Qatar
+🇷🇪 flag: Réunion
+🇷🇴 flag: Romania
+🇷🇸 flag: Serbia
+🇷🇺 flag: Russia
+🇷🇼 flag: Rwanda
+🇸🇦 flag: Saudi Arabia
+🇸🇧 flag: Solomon Islands
+🇸🇨 flag: Seychelles
+🇸🇩 flag: Sudan
+🇸🇪 flag: Sweden
+🇸🇬 flag: Singapore
+🇸🇭 flag: St. Helena
+🇸🇮 flag: Slovenia
+🇸🇯 flag: Svalbard & Jan Mayen
+🇸🇰 flag: Slovakia
+🇸🇱 flag: Sierra Leone
+🇸🇲 flag: San Marino
+🇸🇳 flag: Senegal
+🇸🇴 flag: Somalia
+🇸🇷 flag: Suriname
+🇸🇸 flag: South Sudan
+🇸🇹 flag: São Tomé & Príncipe
+🇸🇻 flag: El Salvador
+🇸🇽 flag: Sint Maarten
+🇸🇾 flag: Syria
+🇸🇿 flag: Eswatini
+🇹🇦 flag: Tristan da Cunha
+🇹🇨 flag: Turks & Caicos Islands
+🇹🇩 flag: Chad
+🇹🇫 flag: French Southern Territories
+🇹🇬 flag: Togo
+🇹🇭 flag: Thailand
+🇹🇯 flag: Tajikistan
+🇹🇰 flag: Tokelau
+🇹🇱 flag: Timor-Leste
+🇹🇲 flag: Turkmenistan
+🇹🇳 flag: Tunisia
+🇹🇴 flag: Tonga
+🇹🇷 flag: Turkey
+🇹🇹 flag: Trinidad & Tobago
+🇹🇻 flag: Tuvalu
+🇹🇼 flag: Taiwan
+🇹🇿 flag: Tanzania
+🇺🇦 flag: Ukraine
+🇺🇬 flag: Uganda
+🇺🇲 flag: U.S. Outlying Islands
+🇺🇳 flag: United Nations
+🇺🇸 flag: United States
+🇺🇾 flag: Uruguay
+🇺🇿 flag: Uzbekistan
+🇻🇦 flag: Vatican City
+🇻🇨 flag: St. Vincent & Grenadines
+🇻🇪 flag: Venezuela
+🇻🇬 flag: British Virgin Islands
+🇻🇮 flag: U.S. Virgin Islands
+🇻🇳 flag: Vietnam
+🇻🇺 flag: Vanuatu
+🇼🇫 flag: Wallis & Futuna
+🇼🇸 flag: Samoa
+🇽🇰 flag: Kosovo
+🇾🇪 flag: Yemen
+🇾🇹 flag: Mayotte
+🇿🇦 flag: South Africa
+🇿🇲 flag: Zambia
+🇿🇼 flag: Zimbabwe
+🏴 flag: England
+🏴 flag: Scotland
+🏴 flag: Wales
diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/EmojiPickerDataTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/EmojiPickerDataTest.kt
new file mode 100644
index 0000000000..c5860ed9de
--- /dev/null
+++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/EmojiPickerDataTest.kt
@@ -0,0 +1,55 @@
+package chat.simplex.app
+
+import chat.simplex.common.views.chat.*
+import kotlin.test.*
+
+// use this command for testing:
+// ./gradlew desktopTest
+class EmojiPickerDataTest {
+ private val skinToneModifiers = 0x1F3FB..0x1F3FF
+
+ @Test
+ fun testBundledDataHasAllCategoriesInOrder() {
+ assertEquals(EmojiCategory.entries.toList(), emojiSections.map { it.category })
+ emojiSections.forEach { assertTrue(it.emojis.isNotEmpty(), "empty category ${it.category}") }
+ assertEquals(1870, emojiSections.sumOf { it.emojis.size })
+ }
+
+ @Test
+ fun testBundledDataEntries() {
+ val all = emojiSections.flatMap { it.emojis }
+ assertEquals(all.size, all.map { it.emoji }.toSet().size, "duplicate emoji")
+ all.forEach {
+ assertTrue(it.emoji.isNotEmpty() && it.name.isNotBlank(), "bad entry $it")
+ assertFalse(it.emoji.codePoints().anyMatch { cp -> cp in skinToneModifiers }, "skin tone variant ${it.name}")
+ }
+ assertEquals(EmojiData("😀", "grinning face"), emojiSections.first().emojis.first())
+ assertEquals(EmojiData("🏴", "flag: Wales"), emojiSections.last().emojis.last())
+ }
+
+ @Test
+ fun testParseEmojiData() {
+ val sections = parseEmojiData(sequenceOf("@Smileys & Emotion", "😀\tgrinning face", "", "@Flags", "🏁\tchequered flag", "#️⃣\tkeycap: #"))
+ assertEquals(
+ listOf(
+ EmojiSection(EmojiCategory.Smileys, listOf(EmojiData("😀", "grinning face"))),
+ EmojiSection(EmojiCategory.Flags, listOf(EmojiData("🏁", "chequered flag"), EmojiData("#️⃣", "keycap: #"))),
+ ),
+ sections
+ )
+ }
+
+ @Test
+ fun testParseUnknownGroupFails() {
+ assertFailsWith { parseEmojiData(sequenceOf("@Component", "🏻\tlight skin tone")) }
+ }
+
+ @Test
+ fun testSearchEmoji() {
+ assertEquals(listOf("❤️"), searchEmoji(emojiSections, "red heart").map { it.emoji })
+ assertEquals(searchEmoji(emojiSections, "red heart"), searchEmoji(emojiSections, " RED Heart "))
+ val flags = searchEmoji(emojiSections, "flag: ")
+ assertTrue(flags.size > 250, "found ${flags.size} flags")
+ assertTrue(searchEmoji(emojiSections, "no such emoji name").isEmpty())
+ }
+}
diff --git a/apps/multiplatform/product/README.md b/apps/multiplatform/product/README.md
index 5dc606a50e..3dff974366 100644
--- a/apps/multiplatform/product/README.md
+++ b/apps/multiplatform/product/README.md
@@ -307,7 +307,7 @@ UserPicker
| | OR (when no chat selected): | GroupProfileView |
| UserPicker (overlay) | "No selected chat" | AddGroupMembersView |
| - profile switcher | | MemberAdmission |
-| - quick settings | OR (when modal open): | VerifyCodeView |
+| - quick settings | Over them (when modal open): | VerifyCodeView |
| | ModalManager.center content | SettingsView subtabs |
| ModalManager.start | (settings, new chat, etc.) | |
| - secondary modals | | ModalManager.end |
@@ -316,7 +316,7 @@ UserPicker
ModalManager Placement (Desktop):
- ModalManager.start -> left panel overlay (settings subviews)
- - ModalManager.center -> center panel (replaces chat, used when chatId is null)
+ - ModalManager.center -> center panel (drawn over the chat or the placeholder)
- ModalManager.end -> right panel (detail/info views)
- ModalManager.fullscreen -> full window overlay (onboarding, auth, call)
diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md
index be6048554c..b349f2819a 100644
--- a/apps/multiplatform/product/concepts.md
+++ b/apps/multiplatform/product/concepts.md
@@ -22,7 +22,7 @@ This document provides a structured mapping between product-level concepts, thei
| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt`, `GetStakeBanner.kt` | `Controller.hs` (`APIGetChats`) |
| PC2 | Direct Chat | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `ChatInfoView.kt` | `Types.hs` (`Contact`), `Messages.hs` |
| PC3 | Group Chat | [README.md](README.md) (Groups) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `group/GroupChatInfoView.kt` | `Types.hs` (`GroupInfo`, `GroupMember`) |
-| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt` | `Controller.hs` (`APISendMessages`) |
+| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt`, `ComposeEmojiPicker.kt` | `Controller.hs` (`APISendMessages`) |
| PC5 | Message Reactions | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/ChatItemView.kt` (ChatItemReactions composable) | `Controller.hs` (`APIChatItemReaction`) |
| PC6 | Message Editing | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `ChatItemInfoView.kt` | `Controller.hs` (`APIUpdateChatItem`) |
| PC7 | Message Deletion | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/item/MarkedDeletedItemView.kt`, `DeletedItemView.kt` | `Controller.hs` (`APIDeleteChatItem`) |
diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md
index 871e3f31ca..203a725346 100644
--- a/apps/multiplatform/product/gaps.md
+++ b/apps/multiplatform/product/gaps.md
@@ -13,9 +13,10 @@ This document catalogs known gaps in the multiplatform codebase (Android and Des
5. [Documentation: Haskell Store Layer Not Fully Specified](#gap-05-documentation-haskell-store-layer-not-fully-specified)
6. [Desktop: Recording Not Implemented](#gap-06-desktop-recording-not-implemented)
7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented)
-8. [Online Search: Groups Joined Through Another Link Still Appear as Directory Results](#gap-08-online-search-groups-joined-through-another-link-still-appear-as-directory-results)
-9. [Online Search: First-Search Alert Does Not Mention the Server Hosting a Resolved Address](#gap-09-online-search-first-search-alert-does-not-mention-the-server-hosting-a-resolved-address)
-10. [Online Search: New Chat Does Not Narrow Contacts by Name While Typing](#gap-10-online-search-new-chat-does-not-narrow-contacts-by-name-while-typing)
+8. [Desktop: Emoji Picker Without Skin Tones and Localized Search](#gap-08-desktop-emoji-picker-without-skin-tones-and-localized-search)
+9. [Online Search: Groups Joined Through Another Link Still Appear as Directory Results](#gap-09-online-search-groups-joined-through-another-link-still-appear-as-directory-results)
+10. [Online Search: First-Search Alert Does Not Mention the Server Hosting a Resolved Address](#gap-10-online-search-first-search-alert-does-not-mention-the-server-hosting-a-resolved-address)
+11. [Online Search: New Chat Does Not Narrow Contacts by Name While Typing](#gap-11-online-search-new-chat-does-not-narrow-contacts-by-name-while-typing)
---
@@ -295,7 +296,33 @@ This directly undermines RULE-02 (Database Encryption at Rest) and RULE-04 (Self
---
-## GAP-08: Online Search: Groups Joined Through Another Link Still Appear as Directory Results
+## GAP-08: Desktop: Emoji Picker Without Skin Tones and Localized Search
+
+**Severity:** Low
+**Category:** Feature / Platform
+**Platform:** Desktop only
+
+### Description
+
+The desktop compose emoji picker lists emoji without skin tone variants, and its search matches English Unicode emoji names only.
+
+### Affected Locations
+
+- `common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt`
+- `scripts/desktop/generate-emoji-data.py` (`../../scripts/desktop/` relative to `apps/multiplatform/`) -- drops skin tone variants
+
+### Impact
+
+Desktop users cannot pick a skin tone in the picker, and search in other languages finds nothing.
+
+### Recommendation
+
+1. Keep the skin tone variants in the generated data, grouped by base emoji, and add a skin tone choice to the picker.
+2. Add localized names (CLDR annotations) to the generated data for search.
+
+---
+
+## GAP-09: Online Search: Groups Joined Through Another Link Still Appear as Directory Results
**Severity:** Low
**Category:** UI / UX
@@ -321,7 +348,7 @@ Have the directory send a stable group identifier the core can match locally, or
---
-## GAP-09: Online Search: First-Search Alert Does Not Mention the Server Hosting a Resolved Address
+## GAP-10: Online Search: First-Search Alert Does Not Mention the Server Hosting a Resolved Address
**Severity:** Low
**Category:** Privacy / UX
@@ -347,7 +374,7 @@ Decide whether the alert copy should also say that the address a name points to
---
-## GAP-10: Online Search: New Chat Does Not Narrow Contacts by Name While Typing
+## GAP-11: Online Search: New Chat Does Not Narrow Contacts by Name While Typing
**Severity:** Low
**Category:** UI / UX
diff --git a/apps/multiplatform/product/rules.md b/apps/multiplatform/product/rules.md
index 1b69e96a3f..6dc1e0022e 100644
--- a/apps/multiplatform/product/rules.md
+++ b/apps/multiplatform/product/rules.md
@@ -300,7 +300,7 @@ This document specifies invariants enforced by the Android and Desktop (Kotlin/C
### RULE-22: Nothing Is Shown Twice
-**Invariant:** A name result whose chat is shown in the local list MUST be hidden. A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list or found by name, or when its link is the link a name was resolved through. Only store-derived chat ids and links resolved by the core may hide a row. Limit: a regular group joined through another link (or the directory's full link) is not found by the lookup and still appears as a directory result (GAP-08).
+**Invariant:** A name result whose chat is shown in the local list MUST be hidden. A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list or found by name, or when its link is the link a name was resolved through. Only store-derived chat ids and links resolved by the core may hide a row. Limit: a regular group joined through another link (or the directory's full link) is not found by the lookup and still appears as a directory result (GAP-09).
**Enforcement:** `OnlineSearchModel.rows(shownChatIds)` filters at render time with the ids of the chats each screen shows; `planLocalChatId` derives a result's local chat id from the connection plan, and `NameSearchResult.resolvedLink` is the link the core resolved.
diff --git a/apps/multiplatform/product/views/chat.md b/apps/multiplatform/product/views/chat.md
index 7862574f04..c920e40146 100644
--- a/apps/multiplatform/product/views/chat.md
+++ b/apps/multiplatform/product/views/chat.md
@@ -95,6 +95,7 @@ Bottom input area for composing messages:
|---|---|
| Text field | `PlatformTextField` with markdown support, `@mention` autocomplete, file paste support |
| Attachment button | Opens `ModalBottomSheetLayout` with options: camera, gallery (image/video), file |
+| Emoji button | Desktop only (Android keyboards have their own emoji panel). Opens a popup with search and categories; a picked emoji is inserted at the cursor and the picker stays open. With text selected the emoji goes after it, as the text field drops its selection when the picker takes focus. Picked emoji are not remembered, for privacy. Esc, a click outside or the button closes it |
| Send button | Sends message; changes to checkmark for reports; animated size/alpha |
| Voice record button | Shown when text is empty and voice allowed; hold to record, release to preview |
| Live message button | Start/update live typing message (if `liveMessageAlertShown`) |
@@ -132,4 +133,5 @@ When `timedMessageAllowed` is true, compose bar includes a timer icon for settin
| `ChatView.kt` | `views/chat/ChatView.kt` |
| `ComposeView.kt` | `views/chat/ComposeView.kt` |
| `SendMsgView.kt` | `views/chat/SendMsgView.kt` |
+| `ComposeEmojiPicker.kt` | `views/chat/ComposeEmojiPicker.kt` (+ `.android.kt`, `.desktop.kt`) |
| Chat item views | `views/chat/item/*.kt` |
diff --git a/apps/multiplatform/product/views/user-profiles.md b/apps/multiplatform/product/views/user-profiles.md
index dfc37a5e8d..38a0e13874 100644
--- a/apps/multiplatform/product/views/user-profiles.md
+++ b/apps/multiplatform/product/views/user-profiles.md
@@ -28,8 +28,8 @@ Overlay panel triggered from `ChatListView` toolbar:
| Device picker row | `DevicePickerRow` showing local device and connected remote hosts (Desktop only); pill-shaped buttons with connect/disconnect actions |
| Active user profile | `ProfilePreview` of current user (Desktop: single row; Android: full user list) |
| User list | `UserPickerUsersSection` with all visible non-hidden profiles; tap to switch, long-press disabled |
-| SimpleX address | Row to open `UserAddressView` (create or view address) |
-| Chat preferences | Row to open `PreferencesView` |
+| SimpleX address | Row to open `UserAddressView` (create or view address); hidden on Desktop when there is no active profile and no mobile is in use; a screen shown with no active profile closes itself (if another screen covers it, when the user goes back to it) |
+| Chat preferences | Row to open `PreferencesView`; hidden on Desktop when there is no active profile and no mobile is in use; a screen shown with no active profile closes itself (if another screen covers it, when the user goes back to it) |
| Chat profiles | Row to open `UserProfilesView` (or `CreateProfile` when no users exist on Desktop) |
| Use from desktop/mobile | Android: "Use from desktop" (`ConnectDesktopView`); Desktop: "Link a mobile" / "Linked mobiles" (`ConnectMobileView`) |
| Settings | Row to open `SettingsView` with `ColorModeSwitcher` trailing |
diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md
index e3676477a1..9b1784d48a 100644
--- a/apps/multiplatform/spec/client/chat-list.md
+++ b/apps/multiplatform/spec/client/chat-list.md
@@ -281,6 +281,9 @@ fun UserPicker(
- **Android:** Renders as a slide-up overlay panel on the chat list, triggered by tapping the user profile button in the toolbar.
- **Desktop:** Rendered inline in the left column of `DesktopScreen`, always accessible.
- Closes automatically when any `ModalManager.start` modal opens.
+- On Desktop, when there is no active user and no mobile is in use (`desktopNoUserNoRemote`), the SimpleX address and Chat preferences rows and the divider below them are hidden.
+- The SimpleX address row opens `UserAddressView` in `ModalManager.start`. With no active user (`currentUser == null`) the modal draws nothing and closes itself when it is the top modal (`ModalManager.start.isLastModal`); if another screen covers it, it closes when the user goes back to it.
+- The Chat preferences row closes its modal the same way when there is no active user.
### Content
@@ -457,7 +460,7 @@ For each new entry of a page (not already shown, not repeated in the page), the
Only store-derived chat ids and the link the core resolved can hide a row, never a field the directory or a name's owner wrote. A mismatch in link encoding can only produce a duplicate row.
-[GAP] A regular group the user joined through a different link, or through the directory's full link, is not found by the store lookup and still appears as a Directory row; tapping it goes through `planAndConnect`. See [GAP-08](../../product/gaps.md#gap-08-online-search-groups-joined-through-another-link-still-appear-as-directory-results).
+[GAP] A regular group the user joined through a different link, or through the directory's full link, is not found by the store lookup and still appears as a Directory row; tapping it goes through `planAndConnect`. See [GAP-09](../../product/gaps.md#gap-09-online-search-groups-joined-through-another-link-still-appear-as-directory-results).
### Rendering
@@ -479,4 +482,4 @@ Each part renders only when it has rows: the Names part when `rows.names` is not
A name or directory row tap first hides the keyboard (`hideKeyboard(view)`), then runs `planAndConnect` with `withBGApi`. Text from the directory and from name owners is rendered with `Text(String)` (never parsed as markdown) with line limits.
-[GAP] Resolving a SimpleX name with `PRMUnknown` also loads the resolved address's link data from the server that hosts it; the alert names only the directory and the name-resolving server. See [GAP-09](../../product/gaps.md#gap-09-online-search-first-search-alert-does-not-mention-the-server-hosting-a-resolved-address).
+[GAP] Resolving a SimpleX name with `PRMUnknown` also loads the resolved address's link data from the server that hosts it; the alert names only the directory and the name-resolving server. See [GAP-10](../../product/gaps.md#gap-10-online-search-first-search-alert-does-not-mention-the-server-hosting-a-resolved-address).
diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md
index adf8ad0224..3bad2e09c4 100644
--- a/apps/multiplatform/spec/client/chat-view.md
+++ b/apps/multiplatform/spec/client/chat-view.md
@@ -313,6 +313,7 @@ Key sections: group profile, group link, member list with roles, group preferenc
| `EmojiItemView.kt` | Large emoji-only message |
| `FramedItemView.kt` | Message bubble frame (quoted item, text, media) |
| `ImageFullScreenView.kt` | Fullscreen image gallery |
+| `MarkdownFileView.kt` | Markdown file viewer, formatted or as text |
| `IntegrityErrorItemView.kt` | Message integrity error |
| `MarkedDeletedItemView.kt` | Marked-as-deleted / moderated message |
| `TextItemView.kt` | Plain text message with markdown |
diff --git a/apps/multiplatform/spec/client/compose.md b/apps/multiplatform/spec/client/compose.md
index 241dcf667b..e7f879f5b0 100644
--- a/apps/multiplatform/spec/client/compose.md
+++ b/apps/multiplatform/spec/client/compose.md
@@ -14,6 +14,7 @@ Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView
6. [Attachment Handling](#6-attachment-handling)
7. [Draft Persistence](#7-draft-persistence)
8. [Source Files](#8-source-files)
+9. [Emoji Picker](#9-emoji-picker)
---
@@ -41,9 +42,10 @@ ComposeView
| |-- ComposeImageView [media thumbnails]
| |-- ComposeVoiceView [voice recording waveform]
| +-- ComposeFileView [file name display]
-|-- AttachmentAndCommandsButtons
+|-- ComposeActionButtons
| |-- CommandsButton [bot commands "//"]
-| +-- AttachmentButton [paperclip icon]
+| |-- AttachmentButton [paperclip icon]
+| +-- ComposeEmojiButton [desktop emoji picker popup]
+-- SendMsgView
|-- PlatformTextField [multiline text input]
|-- DeleteTextButton [clear text, shown on long text]
@@ -348,7 +350,7 @@ When `privacyLinkPreviews` is enabled and the message contains a URL:
## 7. Draft Persistence
-**Location:** [`ComposeView.kt#L1230`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1230) (`KeyChangeEffect(chatModel.chatId.value)`)
+**Location:** [`ComposeView.kt#L1394`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1394) (`KeyChangeEffect(chatModel.chatId.value)`)
Controlled by the `privacySaveLastDraft` preference.
@@ -397,3 +399,27 @@ When a draft exists for a chat, `ChatPreviewView` shows a pencil icon with the d
| `ComposeContextPendingMemberActionsView.kt` | Pending member compose actions |
| `ComposeContextProfilePickerView.kt` | Profile picker in compose context |
| `SelectableChatItemToolbars.kt` | Multi-select mode toolbar (delete, forward, moderate) |
+| `ComposeEmojiPicker.kt` (+ `.android.kt`, `.desktop.kt`) | Desktop emoji button and picker, emoji insertion |
+
+---
+
+
+
+## 9. Emoji Picker
+
+**Source:** [`ComposeEmojiPicker.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt), [`ComposeEmojiPicker.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt)
+
+Desktop only; on Android the [`ComposeEmojiButton()`](../../common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt) actual is empty, as keyboards have their own emoji panel.
+
+An emoji button in [`ComposeActionButtons()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1346-L1363) toggles `emojiPickerShown`, owned by `ComposeView`. A picked emoji goes through [`onEmoji()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1290-L1292): [`insertingEmoji()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt#L10-L14) replaces the selection (clamped to the text) and moves the cursor after the emoji, then `onMessageChange` applies markdown parsing, link sanitizing and the large-emoji font. Emoji are ignored while a message is in progress. The picker stays open after picking. The text field collapses its selection to the end when the popup takes focus, so the emoji goes after selected text.
+
+[`emojiEnabled`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1282-L1288) is false when sending is not allowed or a voice message is previewed; the picker closes then. When the picker closes, the text field gets focus through `focusRequester`.
+
+| Function | Behavior |
+|---|---|
+| [`ComposeEmojiButton()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L89-L115) | Button 4 dp after the attachment button (`EMOJI_BUTTON_START_PADDING`, against the row's -8 dp overlap). Focusable `Popup` above the button; closes on Esc (on key release, so the window does not also close its top modal), outside click or the button; its height is capped to the space above the button |
+| [`EmojiPicker()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L127-L178) | Search field, category tabs, `LazyVerticalGrid` with one section per `EmojiCategory`; tabs and cells are not focusable, so typing after a click goes to search. Picked emoji are not stored, so nothing about what the user typed is kept in preferences |
+
+Data: `desktopMain/resources/emoji/emoji.txt` is generated by `scripts/desktop/generate-emoji-data.py` from Unicode `emoji-test.txt` 15.0 (the version covered by the bundled NotoColorEmoji): fully-qualified emoji without skin tone variants, `@` lines followed by `\t` lines. [`parseEmojiData()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L59-L76) maps groups to `EmojiCategory` and fails on an unknown group; [`emojiSections`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L78-L82) loads it lazily. [`searchEmoji()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L84-L87) matches the English name case-insensitively.
+
+[GAP] No skin tone selection, and search uses English emoji names only.
diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md
index 36c878176b..ad3357cfc3 100644
--- a/apps/multiplatform/spec/client/navigation.md
+++ b/apps/multiplatform/spec/client/navigation.md
@@ -179,7 +179,7 @@ fun DesktopScreen(userPickerState: MutableStateFlow)
|---|---|---|
| **Left** | `DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier` (fixed) | `StartPartOfScreen` (ChatListView) + `UserPicker` overlay |
| **Left overlay** | Same as left column | `ModalManager.start` modals + `SwitchingUsersView` |
-| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView or "no selected chat" placeholder, or `ModalManager.center`) |
+| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView with `ModalManager.center` over it; without a chat: `ModalManager.center` or "no selected chat" placeholder) |
| **Right** | `max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier` (flexible, 0 when empty) | `ModalManager.end` (ChatInfoView, GroupChatInfoView, ChatItemInfoView, etc.) |
### Column Separators
@@ -197,7 +197,7 @@ When the UserPicker is visible or a start modal is open (but no center modal), a
- When `chatId` is null and no center modals: shows "No selected chat" placeholder.
- When `chatId` is null and center modals open: shows `ModalManager.center`.
-- When `chatId` is set: shows `ChatView`.
+- When `chatId` is set: shows `ChatView`, and `ModalManager.center` over it.
- Automatically closes center modals when a chat is selected.
### StartPartOfScreen
@@ -224,7 +224,7 @@ class ModalManager(private val placement: ModalPlacement?)
| Zone | Android Behavior | Desktop Behavior |
|---|---|---|
| `start` | Shared (same as all others) | Left column overlay, slides from start |
-| `center` | Shared | Center column overlay, replaces ChatView |
+| `center` | Shared | Center column overlay, drawn over ChatView |
| `end` | Shared | Right column, slides from end |
| `fullscreen` | Shared | Fullscreen overlay |
@@ -257,6 +257,7 @@ Each `ModalManager` maintains a stack of `ModalViewHolder` objects with:
| `closeModals` | Pop all modals from the stack |
| `closeModalsExceptFirst` | Pop all but the bottom modal |
| `hasModalsOpen()` | Check if any modals are on the stack |
+| `isLastModal(data)` | Check if the modal with this `ModalData` is the top modal not being removed |
| `showInView` | Render the current modal stack into the composable tree |
### Usage Pattern
@@ -404,4 +405,4 @@ Both triggers do nothing for empty text or a pasted link ([`searchTrimmed()`](..
**Archived contacts.** [`DeletedContactsView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L755-L869) passes no `onSearch` to `ContactsSearchBar`: its field keeps the Search IME label (every `SearchTextField` uses `ImeAction.Search`), the key does nothing, and it shows no online rows; its empty state is unchanged.
-[GAP] New chat does not resolve a typed name locally while typing (the `ContactsSearchBar` collector resolves a name only when its "Connect to …" row is tapped), so a known contact whose name does not contain the typed text is not in "Your contacts" and appears as a Names row; tapping it opens the existing chat. See [GAP-10](../../product/gaps.md#gap-10-online-search-new-chat-does-not-narrow-contacts-by-name-while-typing).
+[GAP] New chat does not resolve a typed name locally while typing (the `ContactsSearchBar` collector resolves a name only when its "Connect to …" row is tapped), so a known contact whose name does not contain the typed text is not in "Your contacts" and appears as a Names row; tapping it opens the existing chat. See [GAP-11](../../product/gaps.md#gap-11-online-search-new-chat-does-not-narrow-contacts-by-name-while-typing).
diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md
index 4c7045f961..69cc463ea7 100644
--- a/apps/multiplatform/spec/impact.md
+++ b/apps/multiplatform/spec/impact.md
@@ -114,6 +114,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
| `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features |
| `views/chat/ComposeView.kt` | PC4, PC6, PC9, PC10, PC11 | High | Message composition — send path for all messages |
| `views/chat/SendMsgView.kt` | PC4, PC9 | Medium | Send button and voice record toggle |
+| `views/chat/ComposeEmojiPicker.kt` | PC4 | Low | Emoji button expect declaration, emoji insertion |
| `views/chat/ComposeVoiceView.kt` | PC9 | Medium | Voice message recording UI |
| `views/chat/ComposeFileView.kt` | PC10 | Low | File attachment preview in compose area |
| `views/chat/ComposeImageView.kt` | PC10 | Low | Image attachment preview in compose area |
@@ -368,6 +369,7 @@ Path prefix: `common/src/androidMain/kotlin/chat/simplex/common/`
| `views/call/CallAudioDeviceManager.kt` | PC17 | Medium | Android audio routing (speaker, earpiece, bluetooth) |
| `views/chat/ComposeView.android.kt` | PC4, PC10 | Low | Android compose view extensions |
| `views/chat/SendMsgView.android.kt` | PC4 | Low | Android send button extensions |
+| `views/chat/ComposeEmojiPicker.android.kt` | PC4 | Low | Empty emoji button actual (no picker on Android) |
| `views/chat/item/ChatItemView.android.kt` | PC2, PC3 | Low | Android chat item extensions |
| `views/chat/item/CIImageView.android.kt` | PC10 | Low | Android image rendering extensions |
| `views/chat/item/CIVideoView.android.kt` | PC10 | Low | Android video rendering extensions |
@@ -442,6 +444,7 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/`
| `views/call/CallView.desktop.kt` | PC17 | Medium | Desktop WebView-based WebRTC call |
| `views/chat/ComposeView.desktop.kt` | PC4, PC10 | Low | Desktop compose view (drag-and-drop, paste) |
| `views/chat/SendMsgView.desktop.kt` | PC4 | Low | Desktop send shortcut (Enter key handling) |
+| `views/chat/ComposeEmojiPicker.desktop.kt` | PC4 | Low | Desktop emoji picker popup (data from `resources/emoji/emoji.txt`, search) |
| `views/chat/item/ChatItemView.desktop.kt` | PC2, PC3 | Low | Desktop chat item extensions |
| `views/chat/item/CIImageView.desktop.kt` | PC10 | Low | Desktop image rendering |
| `views/chat/item/CIVideoView.desktop.kt` | PC10 | Low | Desktop video rendering |
diff --git a/apps/simplex-calculator-bot/README.md b/apps/simplex-calculator-bot/README.md
index 028e470964..d8985700cd 100644
--- a/apps/simplex-calculator-bot/README.md
+++ b/apps/simplex-calculator-bot/README.md
@@ -25,7 +25,7 @@ The bot prints its address and keeps its data in `./data`.
To add a SimpleX name to the address, register the name with the address short link, and run `npm start -- --domain yourname.simplex`. The name is kept when the bot starts without `--domain`.
-To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`.
+To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`, then start the bot with `SIMPLEX_ADDON_PATH=../../packages/simplex-chat-nodejs/build/Release/simplex.node`.
## Test
diff --git a/apps/simplex-support-bot/README.md b/apps/simplex-support-bot/README.md
index 19b9ab8bd6..d95aa5b59b 100644
--- a/apps/simplex-support-bot/README.md
+++ b/apps/simplex-support-bot/README.md
@@ -12,30 +12,18 @@ A business-address bot that triages incoming support chats, optionally runs them
```bash
cd apps/simplex-support-bot
-npm install # downloads native libs + transitive deps
+npm install
npm run build # tsc
```
-By default this installs the **SQLite** backend.
+The native library is downloaded on first start into the user cache. To download it ahead of time (for example in a Dockerfile), run as the user that runs the bot:
-To use **PostgreSQL** instead, drop a `.npmrc` next to `package.json` *before* `npm install`:
-
-```bash
-echo 'simplex_backend=postgres' > .npmrc
-npm install # now pulls postgres-flavored native libs
-npm run build
+```sh
+npx simplex-chat install --backend sqlite
+npx simplex-chat install --backend postgres # Linux x86_64 only
```
-`.npmrc` lives next to the package — npm reads it natively, no extra setup.
-
-### Switching backends
-
-`npm install` is a no-op for already-installed deps, so editing `.npmrc` and re-running `npm install` will *not* re-trigger `simplex-chat`'s preinstall. To switch backends, force a clean install:
-
-```bash
-rm -rf node_modules
-npm install # download-libs.js re-runs and pulls the right native lib
-```
+Select the database with `--db sqlite` (default) or `--db postgres`.
## Run
@@ -47,11 +35,9 @@ npm start -- --team-group "Support Team"
# PostgreSQL
npm start -- --team-group "Support Team" \
- --pg-conn "postgres://user:pass@host/db"
+ --db postgres --pg-conn "postgres://user:pass@host/db"
```
-The bot runs via `npm start` so npm can expose `.npmrc` settings to the process — `detectBackend()` reads `npm_config_simplex_backend` to know which backend was installed.
-
## Flags
Run `npm start -- --help` for the auto-generated reference. Summary:
@@ -60,6 +46,7 @@ Run `npm start -- --help` for the auto-generated reference. Summary:
|---|---|---|---|---|
| `--team-group` | both | yes | — | team group display name |
| `--state-file` | both | no | `./data/state.json` | path to bot state JSON |
+| `--db` | both | no | `sqlite` | `sqlite` or `postgres` |
| `--sqlite-file-prefix` | sqlite | no | `./data/simplex` | DB file prefix (creates `_chat.db`, `_agent.db`) |
| `--sqlite-key` | sqlite | no | (unencrypted) | SQLCipher encryption key |
| `--pg-conn` | postgres | yes | — | PostgreSQL connection string |
@@ -76,7 +63,8 @@ Run `npm start -- --help` for the auto-generated reference. Summary:
| Var | Purpose |
|---|---|
| `GROK_API_KEY` | xAI API key; enables Grok replies |
-| `SIMPLEX_BACKEND` | alternative to `.npmrc` for selecting the install backend (`sqlite` or `postgres`) |
+| `SIMPLEX_LIBS_DIR` | directory with a local libsimplex build for the `--db` backend, instead of the downloaded one |
+| `SIMPLEX_ADDON_PATH` | locally built Node.js add-on (`simplex.node`), instead of the downloaded one |
## Local development against unreleased lib changes
@@ -84,18 +72,18 @@ This package depends on `simplex-chat` from npm. To test against an in-tree vers
```bash
# In packages/simplex-chat-nodejs
+npm install && npm run build
npm link
# In apps/simplex-support-bot
npm link simplex-chat
+export SIMPLEX_ADDON_PATH=../../packages/simplex-chat-nodejs/build/Release/simplex.node
```
`npm unlink simplex-chat && npm install` reverts to the registry version.
## Troubleshooting
-- **`--pg-conn is required when backend is postgres`** — the postgres backend is installed but you didn't pass a connection string.
+- **`--pg-conn is required with --db postgres`** — pass a connection string, or use `--db sqlite`.
- **`libpq5` errors at startup** — install `libpq5` on the host (`apt install libpq5` on Debian/Ubuntu).
- **`ENOENT: no such file or directory, open './data/state.json'`** — the parent directory of `--state-file` must exist; `mkdir -p data` before starting.
-- **Wrong backend installed** — check `node_modules/simplex-chat/libs/installed.txt`. Edit `.npmrc`, then `rm -rf node_modules && npm install` to switch (`npm install` alone won't re-run the dep's preinstall).
-- **`libpq` connection error** at startup with sqlite-flavored config (or vice versa) — `.npmrc` was changed but libs weren't reinstalled. See "Switching backends" above.
diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts
index 37521e49dc..a9785de1e3 100644
--- a/apps/simplex-support-bot/bot.test.ts
+++ b/apps/simplex-support-bot/bot.test.ts
@@ -2403,54 +2403,48 @@ describe("parseConfig Validation", () => {
.toThrow(/--complete-hours must be a non-negative integer, got "abc"/)
})
- test("postgres backend without --pg-conn → throws", () => {
- const prev = process.env.SIMPLEX_BACKEND
- process.env.SIMPLEX_BACKEND = "postgres"
- try {
- expect(() => parseConfig(baseArgs))
- .toThrow(/--pg-conn is required when backend is postgres/)
- } finally {
- if (prev === undefined) delete process.env.SIMPLEX_BACKEND
- else process.env.SIMPLEX_BACKEND = prev
- }
+ test("--db postgres without --pg-conn → throws", () => {
+ expect(() => parseConfig([...baseArgs, "--db", "postgres"]))
+ .toThrow(/--pg-conn is required with --db postgres/)
})
- test("postgres backend with --pg-conn → db is postgres DbConfig", () => {
- const prev = process.env.SIMPLEX_BACKEND
- process.env.SIMPLEX_BACKEND = "postgres"
- try {
- const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://user:pass@localhost/db"])
- expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://user:pass@localhost/db"})
- } finally {
- if (prev === undefined) delete process.env.SIMPLEX_BACKEND
- else process.env.SIMPLEX_BACKEND = prev
- }
+ test("--db postgres with --pg-conn → db is postgres DbConfig", () => {
+ const cfg = parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "postgres://user:pass@localhost/db"])
+ expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://user:pass@localhost/db"})
})
- test("postgres backend with --pg-schema → DbConfig carries schemaPrefix", () => {
- const prev = process.env.SIMPLEX_BACKEND
- process.env.SIMPLEX_BACKEND = "postgres"
- try {
- const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://localhost/db", "--pg-schema", "bot"])
- expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://localhost/db", schemaPrefix: "bot"})
- } finally {
- if (prev === undefined) delete process.env.SIMPLEX_BACKEND
- else process.env.SIMPLEX_BACKEND = prev
- }
+ test("--db postgres with --pg-schema → DbConfig carries schemaPrefix", () => {
+ const cfg = parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "postgres://localhost/db", "--pg-schema", "bot"])
+ expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://localhost/db", schemaPrefix: "bot"})
})
- test("sqlite backend (default) → db is sqlite DbConfig with default filePrefix", () => {
- const prevBackend = process.env.SIMPLEX_BACKEND
- const prevNpm = process.env.npm_config_simplex_backend
- delete process.env.SIMPLEX_BACKEND
- delete process.env.npm_config_simplex_backend
- try {
- const cfg = parseConfig(baseArgs)
- expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex"})
- } finally {
- if (prevBackend !== undefined) process.env.SIMPLEX_BACKEND = prevBackend
- if (prevNpm !== undefined) process.env.npm_config_simplex_backend = prevNpm
- }
+ test("default --db → sqlite DbConfig with default filePrefix", () => {
+ const cfg = parseConfig(baseArgs)
+ expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex"})
+ })
+
+ test("--pg-conn with sqlite → throws", () => {
+ expect(() => parseConfig([...baseArgs, "--pg-conn", "postgres://localhost/db"]))
+ .toThrow(/--pg-conn and --pg-schema require --db postgres/)
+ })
+
+ test("--pg-schema with sqlite → throws", () => {
+ expect(() => parseConfig([...baseArgs, "--pg-schema", "bot"]))
+ .toThrow(/--pg-conn and --pg-schema require --db postgres/)
+ })
+
+ test("--sqlite-key with postgres → throws", () => {
+ expect(() => parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "c", "--sqlite-key", "k"]))
+ .toThrow(/--sqlite-file-prefix and --sqlite-key require --db sqlite/)
+ })
+
+ test("--sqlite-file-prefix with postgres → throws", () => {
+ expect(() => parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "c", "--sqlite-file-prefix", "./x"]))
+ .toThrow(/--sqlite-file-prefix and --sqlite-key require --db sqlite/)
+ })
+
+ test("invalid --db → throws", () => {
+ expect(() => parseConfig([...baseArgs, "--db", "mysql"])).toThrow(/Allowed choices are sqlite, postgres/)
})
test("sqlite backend with --sqlite-key → DbConfig carries encryptionKey", () => {
@@ -2468,18 +2462,6 @@ describe("parseConfig Validation", () => {
.toThrow(/required option '--team-group/)
})
- test("invalid SIMPLEX_BACKEND → throws", () => {
- const prev = process.env.SIMPLEX_BACKEND
- process.env.SIMPLEX_BACKEND = "mysql"
- try {
- expect(() => parseConfig(baseArgs))
- .toThrow(/Invalid SIMPLEX_BACKEND: "mysql"/)
- } finally {
- if (prev === undefined) delete process.env.SIMPLEX_BACKEND
- else process.env.SIMPLEX_BACKEND = prev
- }
- })
-
test("--complete-hours negative → throws", () => {
// parseArgs refuses "-1" as a bare arg (ambiguous with a short flag), so use `=` form
expect(() => parseConfig([...baseArgs, "--complete-hours", "-1"]))
diff --git a/apps/simplex-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts
index 8fbd006aef..be33db0c38 100644
--- a/apps/simplex-support-bot/src/config.ts
+++ b/apps/simplex-support-bot/src/config.ts
@@ -1,4 +1,4 @@
-import {Command} from "commander"
+import {Command, Option} from "commander"
import {api} from "simplex-chat"
export interface IdName {
@@ -6,8 +6,6 @@ export interface IdName {
name: string
}
-export type Backend = "sqlite" | "postgres"
-
export interface Config {
stateFile: string // local path to the bot's state JSON
db: api.DbConfig // passed to ChatApi.init / bot.run
@@ -21,18 +19,6 @@ export interface Config {
grokApiKey: string | null
}
-// Mirrors packages/simplex-chat-nodejs/src/download-libs.js so runtime detection
-// matches what was used at install time. Works whether the user installed via
-// SIMPLEX_BACKEND env var, .npmrc (→ npm_config_simplex_backend), or the
-// --simplex_backend=postgres CLI flag (also surfaced as npm_config_*).
-export function detectBackend(): Backend {
- const raw = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || "sqlite").toLowerCase()
- if (raw !== "sqlite" && raw !== "postgres") {
- throw new Error(`Invalid SIMPLEX_BACKEND: "${raw}". Must be "sqlite" or "postgres".`)
- }
- return raw
-}
-
export function parseIdName(s: string): IdName {
const i = s.indexOf(":")
if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`)
@@ -57,21 +43,23 @@ function buildCommand(): Command {
.description("business-address triage bot")
.requiredOption("--team-group ", "team group display name")
.option("--state-file ", "state JSON path", "./data/state.json")
+ .addOption(new Option("--db ", "database backend").choices(["sqlite", "postgres"]).default("sqlite"))
.option("--sqlite-file-prefix ", "SQLite DB file prefix", "./data/simplex")
.option("--sqlite-key ", "SQLCipher encryption key (default: unencrypted)")
- .option("--pg-conn ", "PostgreSQL connection string (required for postgres)")
+ .option("--pg-conn ", "PostgreSQL connection string (required with --db postgres)")
.option("--pg-schema ", "PostgreSQL schema prefix (default: simplex_v1)")
.option("-a, --auto-add-team-members ", "comma-separated ID:name pairs (e.g. 1:Alice,2:Bob)")
.option("--timezone ", "IANA timezone for weekend detection", "UTC")
.option("--complete-hours ", "auto-complete chats after N hours idle (0 disables)", parseNonNegativeInt("--complete-hours"), 3)
.option("--card-flush-seconds ", "debounce card state writes", parseNonNegativeInt("--card-flush-seconds"), 300)
.option("--context-file ", "text file with Grok system context (required if GROK_API_KEY set)")
- .addHelpText("after", "\nEnvironment:\n GROK_API_KEY xAI API key — enables Grok replies\n SIMPLEX_BACKEND sqlite | postgres — alternative to .npmrc for backend selection\n")
+ .addHelpText("after", "\nEnvironment:\n GROK_API_KEY xAI API key — enables Grok replies\n SIMPLEX_LIBS_DIR local libsimplex build for the --db backend\n SIMPLEX_ADDON_PATH locally built Node.js add-on\n")
}
interface RawOpts {
teamGroup: string
stateFile: string
+ db: api.DbConfig["type"]
sqliteFilePrefix: string
sqliteKey?: string
pgConn?: string
@@ -93,19 +81,19 @@ export function parseConfig(args: string[]): Config {
throw err
}
const opts = cmd.opts()
+ const given = (option: keyof RawOpts) => cmd.getOptionValueSource(option) === "cli"
const grokApiKey = process.env.GROK_API_KEY || null
- const backend = detectBackend()
let db: api.DbConfig
- if (backend === "sqlite") {
+ if (opts.db === "sqlite") {
+ if (given("pgConn") || given("pgSchema")) throw new Error("--pg-conn and --pg-schema require --db postgres")
db = opts.sqliteKey
? {type: "sqlite", filePrefix: opts.sqliteFilePrefix, encryptionKey: opts.sqliteKey}
: {type: "sqlite", filePrefix: opts.sqliteFilePrefix}
} else {
- if (!opts.pgConn) {
- throw new Error("--pg-conn is required when backend is postgres (PostgreSQL connection string)")
- }
+ if (given("sqliteFilePrefix") || given("sqliteKey")) throw new Error("--sqlite-file-prefix and --sqlite-key require --db sqlite")
+ if (!opts.pgConn) throw new Error("--pg-conn is required with --db postgres")
db = opts.pgSchema
? {type: "postgres", connectionString: opts.pgConn, schemaPrefix: opts.pgSchema}
: {type: "postgres", connectionString: opts.pgConn}
diff --git a/apps/simplex-support-bot/vitest.config.ts b/apps/simplex-support-bot/vitest.config.ts
index c143572a87..3a70f6c5e7 100644
--- a/apps/simplex-support-bot/vitest.config.ts
+++ b/apps/simplex-support-bot/vitest.config.ts
@@ -5,13 +5,6 @@ export default defineConfig({
test: {
globals: true,
testTimeout: 10000,
- // Clear backend signals — .npmrc next to package.json otherwise injects
- // npm_config_simplex_backend into every test's env, breaking sqlite-default
- // assumptions in parseConfig tests.
- env: {
- SIMPLEX_BACKEND: "",
- npm_config_simplex_backend: "",
- },
},
resolve: {
alias: {
diff --git a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md
index 3c3fb7b515..b0e84ada28 100644
--- a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md
+++ b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md
@@ -50,7 +50,7 @@ Other limitations of the desktop app:
- you cannot send voice messages.
- there is no support for calls yet.
-You can download the desktop app for Linux and Mac via [downloads page](../docs/DOWNLOADS.md). Windows version will be available soon.
+You can download the desktop app for Linux and Mac via [downloads page](https://simplex.chat/downloads/). Windows version will be available soon.
## Group directory service and other group improvements
diff --git a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md
index 539d719af4..1a0019216a 100644
--- a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md
+++ b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md
@@ -40,7 +40,7 @@ This is only possible when both devices are connected to the same local network.
**On desktop**
-If you don't have desktop app installed yet, [download it](../docs/DOWNLOADS.md) and create any chat profile - you don't need to use it, and when you create it there are no server requests sent and no accounts are created. Think about it as about user profile on your computer.
+If you don't have desktop app installed yet, [download it](https://simplex.chat/downloads/) and create any chat profile - you don't need to use it, and when you create it there are no server requests sent and no accounts are created. Think about it as about user profile on your computer.
Then in desktop app settings choose *Link a mobile* - it will show a QR code.
diff --git a/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md b/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md
index e06f7c2084..9bd342a146 100644
--- a/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md
+++ b/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md
@@ -110,7 +110,7 @@ We [wrote previously](https://github.com/simplex-chat/simplexmq/blob/master/rfcs
While we cannot require the operators of self-hosted and private servers to disclose any information about them (apart from which server code they use - this is the requirement of the AGPLv3 license to share this information with users connecting to the server), as we add other server operators to the app, it is important for the users to have all important information about these operators and servers location.
-This server release adds server information page where all this information can be made available to the users. For example, this is the information about one of the servers preset in the app.
+This server release adds server information page where all this information can be made available to the users. For example, this is the information about one of the servers preset in the app.
The updated server guide also includes [the instruction](../docs/SERVER.md#) about how to host this page for your server. It is generated as a static page when the server starts. We recommend using Caddy webserver to serve it.
diff --git a/blog/20260919-simplex-supporter-badges.md b/blog/20260919-simplex-supporter-badges.md
index 7aa2b2e4ac..50e2ebcb4b 100644
--- a/blog/20260919-simplex-supporter-badges.md
+++ b/blog/20260919-simplex-supporter-badges.md
@@ -11,7 +11,7 @@ permalink: "/blog/20260919-simplex-supporter-badges.html"
**Published:** Sep 19, 2026
-You can now support SimpleX Chat and get a supporter badge, larger files and longer file storage — from v7.1 beta[^beta]. Watch how to buy a badge.
+You can now support SimpleX Chat and get a supporter badge, larger files and longer file storage — from v7.1 beta[^beta]. Watch how to buy a badge.
## A paid feature that cannot identify you
@@ -28,7 +28,7 @@ Read more about badges in the [whitepaper](https://github.com/simplex-chat/simpl
## How to get a badge
-
+
Buy a code on [simplex.chat/badges](https://simplex.chat/badges/), paying by card, Bitcoin or Monero, and redeem it in the app: open Settings, tap **Supporter perks**, and enter the code. The badge appears on your profile. The badge does not renew by itself, and no account is created.
diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs
index 48f307f6e4..e310fd54ef 100644
--- a/bots/src/API/Docs/Events.hs
+++ b/bots/src/API/Docs/Events.hs
@@ -203,6 +203,7 @@ undocumentedEvents =
"CEvtContactRequestRejected",
"CEvtContactSwitch",
"CEvtCustomChatEvent",
+ "CEvtDuplicateGroupMessage",
"CEvtGroupMemberRatchetSync",
"CEvtGroupMemberSwitch",
"CEvtServiceSubStatus",
diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md
index 95cf972c0d..92c0dac545 100644
--- a/docs/DOWNLOADS.md
+++ b/docs/DOWNLOADS.md
@@ -1,11 +1,12 @@
---
title: Download SimpleX apps
-permalink: /downloads/index.html
revision: 09.09.2024
---
# Download SimpleX apps
+Download the app for your platform: https://simplex.chat/downloads/
+
You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases).
If you cannot access GitHub, you can download SimpleX Chat apps from our mirror at [git.simplex.chat](https://git.simplex.chat/simplex-chat/simplex-chat/releases)
diff --git a/docs/JOIN_TEAM.md b/docs/JOIN_TEAM.md
index edf3ba199b..44e99a42a9 100644
--- a/docs/JOIN_TEAM.md
+++ b/docs/JOIN_TEAM.md
@@ -49,6 +49,6 @@ This is a part-time remote contract. You must be in UTC +/- 8 hours timezone.
## How to join the team
-To apply, please [install SimpleX Chat app](./DOWNLOADS.md) and send your achievements to this [SimpleX address](https://smp16.simplex.im/a#OGL3qf7utOrUERFoFOROgdQaAkj_znzoeACNKDAsFNA).
+To apply, please [install SimpleX Chat app](https://simplex.chat/downloads/) and send your achievements to this [SimpleX address](https://smp16.simplex.im/a#OGL3qf7utOrUERFoFOROgdQaAkj_znzoeACNKDAsFNA).
**We do NOT review CVs at the initial stage, please only send the links to your achievements**.
diff --git a/packages/simplex-chat-nodejs/.gitignore b/packages/simplex-chat-nodejs/.gitignore
index 322e38bfda..4fa6477e38 100644
--- a/packages/simplex-chat-nodejs/.gitignore
+++ b/packages/simplex-chat-nodejs/.gitignore
@@ -2,7 +2,7 @@ node_modules/
package-lock.json
.vscode
build/
-libs/
dist/
coverage/
tmp/
+libs/
diff --git a/packages/simplex-chat-nodejs/.npmignore b/packages/simplex-chat-nodejs/.npmignore
index 26fdd85dff..3e2e84b087 100644
--- a/packages/simplex-chat-nodejs/.npmignore
+++ b/packages/simplex-chat-nodejs/.npmignore
@@ -1,3 +1,2 @@
-libs/
build/
node_modules/
diff --git a/packages/simplex-chat-nodejs/README.md b/packages/simplex-chat-nodejs/README.md
index 05c99eca5c..6ccd26fc05 100644
--- a/packages/simplex-chat-nodejs/README.md
+++ b/packages/simplex-chat-nodejs/README.md
@@ -55,40 +55,38 @@ If you cloned this repository, you can:
cd ./packages/simplex-chat-nodejs
npm install
npm run build
-node ./examples/squaring-bot-readme.js
+SIMPLEX_ADDON_PATH=build/Release/simplex.node node ./examples/squaring-bot-readme.js
```
+Building the Node.js add-on from source needs [node-gyp](https://github.com/nodejs/node-gyp) build tools.
+
There is an example with more options in [./examples/squaring-bot.ts](./examples/squaring-bot.ts).
You can run it with: `npx ts-node ./examples/squaring-bot.ts`
A larger example, with a business address and commands as keys: [SimpleX Calculator](../../apps/simplex-calculator-bot/).
-## PostgreSQL backend
+## Native library
-By default, the package uses SQLite. To use PostgreSQL instead:
+`libsimplex` and the prebuilt Node.js add-on are downloaded on first use into the user cache (`$XDG_CACHE_HOME/simplex-chat` or `~/.cache/simplex-chat` on Linux, `~/Library/Caches/simplex-chat` on macOS, `%LOCALAPPDATA%\simplex-chat` on Windows), shared with the Python library. To download them ahead of time, for example in a Dockerfile, run as the user that runs the app:
```bash
-npm install simplex-chat --simplex_backend=postgres
+npx simplex-chat install # sqlite (default)
+npx simplex-chat install --backend postgres # Linux x86_64 only
```
-Or persist the setting in `.npmrc`:
+Set `SIMPLEX_LIBS_DIR` to a directory with a local build of `libsimplex` and its dependencies to use it instead; it must be built for the backend in use. Set `SIMPLEX_ADDON_PATH` to a locally built add-on (`build/Release/simplex.node`) to use it instead of the downloaded one.
-```ini
-simplex_backend=postgres
-```
+`ChatApi.init` loads the library for `DbConfig.type`; the low-level `core` functions require `core.loadLibrary(backend)` first.
-### Prerequisites (PostgreSQL)
+## PostgreSQL backend
+
+`DbConfig.type` selects the backend; one backend per process.
- `libpq5` must be installed on the host system (`apt install libpq5` on Debian/Ubuntu)
- PostgreSQL backend is only available for Linux x86_64
- A PostgreSQL server accessible via connection string
-### Passing PostgreSQL connection
-
-The `DbConfig` type is a discriminated union — pick the variant that matches
-the backend you installed:
-
```ts
// SQLite (default)
dbOpts: {type: "sqlite", filePrefix: "./data/bot"}
diff --git a/packages/simplex-chat-nodejs/binding.gyp b/packages/simplex-chat-nodejs/binding.gyp
index cfa6d61039..de6dbfbfd4 100644
--- a/packages/simplex-chat-nodejs/binding.gyp
+++ b/packages/simplex-chat-nodejs/binding.gyp
@@ -15,36 +15,8 @@
"msvs_settings": { "VCCLCompilerTool": { "ExceptionHandling": 1 } },
"defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ],
"conditions": [
- ["OS=='mac'", {
- "libraries": [
- "-L<(module_root_dir)/libs",
- "-lsimplex"
- ],
- "xcode_settings": {
- "OTHER_LDFLAGS": [
- "-Wl,-rpath,@loader_path/../../libs"
- ]
- }
- }],
["OS=='linux'", {
- "libraries": [
- "-L<(module_root_dir)/libs",
- "-lsimplex"
- ],
- "ldflags": [
- "-Wl,-rpath,'$$ORIGIN'/../../libs"
- ]
- }],
- ["OS=='win'", {
- "libraries": [
- "<(module_root_dir)/libs/libsimplex.lib"
- ],
- "copies": [{
- "destination": "<(PRODUCT_DIR)",
- "files": [
- "<(module_root_dir)/libs/*"
- ]
- }]
+ "libraries": [ "-ldl" ]
}]
]
}
diff --git a/packages/simplex-chat-nodejs/cpp/simplex.cc b/packages/simplex-chat-nodejs/cpp/simplex.cc
index bcca2b994a..6cc2172ee1 100644
--- a/packages/simplex-chat-nodejs/cpp/simplex.cc
+++ b/packages/simplex-chat-nodejs/cpp/simplex.cc
@@ -8,15 +8,102 @@
#include
#include
#include
+#include
#include
#include
#include
+#ifdef _WIN32
+#include
+#else
+#include
+#endif
#include "simplex.h"
namespace simplex {
using namespace Napi;
+struct Library {
+ std::string path;
+ hs_init_with_rtsopts_fn hs_init_with_rtsopts = nullptr;
+ hs_thread_done_fn hs_thread_done = nullptr;
+ chat_migrate_init_fn chat_migrate_init = nullptr;
+ chat_migrate_init_queue_fn chat_migrate_init_queue = nullptr;
+ chat_close_store_fn chat_close_store = nullptr;
+ chat_send_cmd_fn chat_send_cmd = nullptr;
+ chat_recv_msg_wait_fn chat_recv_msg_wait = nullptr;
+ chat_write_file_fn chat_write_file = nullptr;
+ chat_read_file_fn chat_read_file = nullptr;
+ chat_encrypt_file_fn chat_encrypt_file = nullptr;
+ chat_decrypt_file_fn chat_decrypt_file = nullptr;
+};
+
+static Library lib;
+static std::atomic loaded{false};
+static std::mutex load_mutex;
+
+#ifdef _WIN32
+typedef HMODULE LibHandle;
+
+static LibHandle OpenLibrary(const std::string& path) {
+ int n = MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, nullptr, 0);
+ std::wstring wpath(n, L'\0');
+ MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, &wpath[0], n);
+ // dependencies resolve from the libsimplex.dll, application and system dirs, not the cwd or PATH
+ return LoadLibraryExW(wpath.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
+}
+
+static void CloseLibrary(LibHandle h) {
+ FreeLibrary(h);
+}
+
+static void* FindSymbol(LibHandle h, const char* name) {
+ return reinterpret_cast(GetProcAddress(h, name));
+}
+
+static std::string LastLoadError() {
+ DWORD code = GetLastError();
+ char* msg = nullptr;
+ FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
+ nullptr, code, 0, reinterpret_cast(&msg), 0, nullptr);
+ std::string err = msg ? msg : "error " + std::to_string(code);
+ LocalFree(msg);
+ // system messages end with CRLF
+ err.erase(err.find_last_not_of("\r\n") + 1);
+ return err;
+}
+#else
+typedef void* LibHandle;
+
+static LibHandle OpenLibrary(const std::string& path) {
+ return dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
+}
+
+static void CloseLibrary(LibHandle h) {
+ dlclose(h);
+}
+
+static void* FindSymbol(LibHandle h, const char* name) {
+ return dlsym(h, name);
+}
+
+static std::string LastLoadError() {
+ const char* err = dlerror();
+ return err ? err : "unknown error";
+}
+#endif
+
+template
+static void Resolve(LibHandle h, const char* name, F& fn) {
+ fn = reinterpret_cast(FindSymbol(h, name));
+}
+
+template
+static void ResolveRequired(LibHandle h, const char* name, F& fn, std::string& missing) {
+ Resolve(h, name, fn);
+ if (fn == nullptr && missing.empty()) missing = name;
+}
+
void haskell_init() {
#ifdef _WIN32
// non-moving GC is broken on windows with GHC 9.4-9.6.3
@@ -40,7 +127,57 @@ void haskell_init() {
nullptr};
#endif
char **pargv = const_cast(argv);
- hs_init_with_rtsopts(&argc, &pargv);
+ lib.hs_init_with_rtsopts(&argc, &pargv);
+}
+
+static bool RequireLoaded(Env env) {
+ if (loaded) return true;
+ Error::New(env, "libsimplex is not loaded, call core.loadLibrary(backend) first").ThrowAsJavaScriptException();
+ return false;
+}
+
+Value Load(const CallbackInfo& args) {
+ Env env = args.Env();
+ if (args.Length() < 1 || !args[0].IsString()) {
+ TypeError::New(env, "Expected string (libPath)").ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
+ std::string path = args[0].As().Utf8Value();
+ // worker_threads may load concurrently; the Haskell runtime must be initialized once per process
+ std::lock_guard lock(load_mutex);
+ if (loaded) {
+ if (path != lib.path) Error::New(env, "libsimplex already loaded from " + lib.path).ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
+ LibHandle h = OpenLibrary(path);
+ if (h == nullptr) {
+ std::string err = LastLoadError();
+ Error::New(env, "cannot load " + path + ": " + err).ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
+ Library l;
+ l.path = path;
+ std::string missing;
+ ResolveRequired(h, "hs_init_with_rtsopts", l.hs_init_with_rtsopts, missing);
+ ResolveRequired(h, "chat_migrate_init", l.chat_migrate_init, missing);
+ ResolveRequired(h, "chat_close_store", l.chat_close_store, missing);
+ ResolveRequired(h, "chat_send_cmd", l.chat_send_cmd, missing);
+ ResolveRequired(h, "chat_recv_msg_wait", l.chat_recv_msg_wait, missing);
+ ResolveRequired(h, "chat_write_file", l.chat_write_file, missing);
+ ResolveRequired(h, "chat_read_file", l.chat_read_file, missing);
+ ResolveRequired(h, "chat_encrypt_file", l.chat_encrypt_file, missing);
+ ResolveRequired(h, "chat_decrypt_file", l.chat_decrypt_file, missing);
+ if (!missing.empty()) {
+ CloseLibrary(h);
+ Error::New(env, path + " does not export " + missing).ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
+ Resolve(h, "chat_migrate_init_queue", l.chat_migrate_init_queue);
+ Resolve(h, "hs_thread_done", l.hs_thread_done);
+ lib = l;
+ haskell_init();
+ loaded = true;
+ return env.Undefined();
}
class ResultAsyncWorker : public AsyncWorker {
@@ -287,7 +424,7 @@ class Receiver {
request = std::move(queue_.front());
queue_.pop_front();
}
- char* c_res = chat_recv_msg_wait(ctrl_, request.wait);
+ char* c_res = lib.chat_recv_msg_wait(ctrl_, request.wait);
napi_status status = Settle(request.deferred, [c_res](Napi::Env env, Promise::Deferred& deferred) {
if (c_res == nullptr) {
deferred.Reject(Error::New(env, "chat_recv_msg_wait failed").Value());
@@ -312,7 +449,7 @@ class Receiver {
}
pending_->Tsfn().Release();
// Each OS thread that enters Haskell keeps an RTS task record until it calls hs_thread_done.
- hs_thread_done();
+ if (lib.hs_thread_done) lib.hs_thread_done();
}
template
@@ -361,6 +498,7 @@ ResultAsyncWorker::ResultProcessor MigrateResultProcessor() {
Value ChatMigrateInit(const CallbackInfo& args) {
Env env = args.Env();
+ if (!RequireLoaded(env)) return env.Undefined();
if (args.Length() < 3 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString()) {
TypeError::New(env, "Expected three string arguments").ThrowAsJavaScriptException();
return env.Undefined();
@@ -375,7 +513,7 @@ Value ChatMigrateInit(const CallbackInfo& args) {
auto execute_fn = [path, key, confirm](ResultAsyncWorker* worker) {
chat_ctrl ctrl = nullptr;
- char* c_res = chat_migrate_init(path.c_str(), key.c_str(), confirm.c_str(), &ctrl);
+ char* c_res = lib.chat_migrate_init(path.c_str(), key.c_str(), confirm.c_str(), &ctrl);
worker->SetCtrl(reinterpret_cast(ctrl));
HandleCResult(worker, c_res, "chat_migrate_init");
};
@@ -388,6 +526,11 @@ Value ChatMigrateInit(const CallbackInfo& args) {
Value ChatMigrateInitQueue(const CallbackInfo& args) {
Env env = args.Env();
+ if (!RequireLoaded(env)) return env.Undefined();
+ if (lib.chat_migrate_init_queue == nullptr) {
+ Error::New(env, "loaded libsimplex does not export chat_migrate_init_queue; queue size needs a newer libsimplex").ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsNumber()) {
TypeError::New(env, "Expected three string arguments and number").ThrowAsJavaScriptException();
return env.Undefined();
@@ -408,7 +551,7 @@ Value ChatMigrateInitQueue(const CallbackInfo& args) {
auto execute_fn = [path, key, confirm, queue_size](ResultAsyncWorker* worker) {
chat_ctrl ctrl = nullptr;
- char* c_res = chat_migrate_init_queue(path.c_str(), key.c_str(), confirm.c_str(), queue_size, &ctrl);
+ char* c_res = lib.chat_migrate_init_queue(path.c_str(), key.c_str(), confirm.c_str(), queue_size, &ctrl);
worker->SetCtrl(reinterpret_cast(ctrl));
HandleCResult(worker, c_res, "chat_migrate_init_queue");
};
@@ -421,6 +564,7 @@ Value ChatMigrateInitQueue(const CallbackInfo& args) {
Value ChatCloseStore(const CallbackInfo& args) {
Env env = args.Env();
+ if (!RequireLoaded(env)) return env.Undefined();
if (args.Length() < 1 || !args[0].IsBigInt()) {
TypeError::New(env, "Expected bigint (ctrl)").ThrowAsJavaScriptException();
return env.Undefined();
@@ -436,7 +580,7 @@ Value ChatCloseStore(const CallbackInfo& args) {
if (receiver) {
receiver->Stop();
}
- char* c_res = chat_close_store(ctrl);
+ char* c_res = lib.chat_close_store(ctrl);
HandleCResult(worker, c_res, "chat_close_store");
};
@@ -448,6 +592,7 @@ Value ChatCloseStore(const CallbackInfo& args) {
Value ChatSendCmd(const CallbackInfo& args) {
Env env = args.Env();
+ if (!RequireLoaded(env)) return env.Undefined();
if (args.Length() < 2 || !args[0].IsBigInt() || !args[1].IsString()) {
TypeError::New(env, "Expected bigint (ctrl) and string (cmd)").ThrowAsJavaScriptException();
return env.Undefined();
@@ -460,7 +605,7 @@ Value ChatSendCmd(const CallbackInfo& args) {
Promise promise = CreatePromiseAndCallback(env, cb);
auto execute_fn = [ctrl, cmd](ResultAsyncWorker* worker) {
- char* c_res = chat_send_cmd(ctrl, cmd.c_str());
+ char* c_res = lib.chat_send_cmd(ctrl, cmd.c_str());
HandleCResult(worker, c_res, "chat_send_cmd");
};
@@ -472,6 +617,7 @@ Value ChatSendCmd(const CallbackInfo& args) {
Value ChatRecvMsgWait(const CallbackInfo& args) {
Env env = args.Env();
+ if (!RequireLoaded(env)) return env.Undefined();
if (args.Length() < 2 || !args[0].IsBigInt() || !args[1].IsNumber()) {
TypeError::New(env, "Expected bigint (ctrl), number (wait)").ThrowAsJavaScriptException();
return env.Undefined();
@@ -503,6 +649,7 @@ Value ChatRecvMsgWait(const CallbackInfo& args) {
Value ChatWriteFile(const CallbackInfo& args) {
Env env = args.Env();
+ if (!RequireLoaded(env)) return env.Undefined();
if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !(args[2].IsArrayBuffer() || args[2].IsTypedArray())) {
TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer or Uint8Array").ThrowAsJavaScriptException();
return env.Undefined();
@@ -530,7 +677,7 @@ Value ChatWriteFile(const CallbackInfo& args) {
Promise promise = CreatePromiseAndCallback(env, cb);
auto execute_fn = [ctrl, path, data, len](ResultAsyncWorker* worker) {
- char* c_res = chat_write_file(ctrl, path.c_str(), data, static_cast(len));
+ char* c_res = lib.chat_write_file(ctrl, path.c_str(), data, static_cast(len));
HandleCResult(worker, c_res, "chat_write_file");
};
@@ -543,6 +690,7 @@ Value ChatWriteFile(const CallbackInfo& args) {
Value ChatReadFile(const CallbackInfo& args) {
Env env = args.Env();
+ if (!RequireLoaded(env)) return env.Undefined();
if (args.Length() < 3 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString()) {
TypeError::New(env, "Expected three strings (path, key, nonce)").ThrowAsJavaScriptException();
return env.Undefined();
@@ -556,7 +704,7 @@ Value ChatReadFile(const CallbackInfo& args) {
Promise promise = CreatePromiseAndCallback(env, cb);
auto execute_fn = [path, key, nonce](BinaryAsyncWorker* worker) {
- char* buf = chat_read_file(path.c_str(), key.c_str(), nonce.c_str());
+ char* buf = lib.chat_read_file(path.c_str(), key.c_str(), nonce.c_str());
if (buf == nullptr) {
worker->SetWorkerError("chat_read_file failed");
return;
@@ -586,6 +734,7 @@ Value ChatReadFile(const CallbackInfo& args) {
Value ChatEncryptFile(const CallbackInfo& args) {
Env env = args.Env();
+ if (!RequireLoaded(env)) return env.Undefined();
if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !args[2].IsString()) {
TypeError::New(env, "Expected bigint (ctrl), two strings (fromPath, toPath)").ThrowAsJavaScriptException();
return env.Undefined();
@@ -599,7 +748,7 @@ Value ChatEncryptFile(const CallbackInfo& args) {
Promise promise = CreatePromiseAndCallback(env, cb);
auto execute_fn = [ctrl, fromPath, toPath](ResultAsyncWorker* worker) {
- char* c_res = chat_encrypt_file(ctrl, fromPath.c_str(), toPath.c_str());
+ char* c_res = lib.chat_encrypt_file(ctrl, fromPath.c_str(), toPath.c_str());
HandleCResult(worker, c_res, "chat_encrypt_file");
};
@@ -611,6 +760,7 @@ Value ChatEncryptFile(const CallbackInfo& args) {
Value ChatDecryptFile(const CallbackInfo& args) {
Env env = args.Env();
+ if (!RequireLoaded(env)) return env.Undefined();
if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsString()) {
TypeError::New(env, "Expected four strings (fromPath, key, nonce, toPath)").ThrowAsJavaScriptException();
return env.Undefined();
@@ -625,7 +775,7 @@ Value ChatDecryptFile(const CallbackInfo& args) {
Promise promise = CreatePromiseAndCallback(env, cb);
auto execute_fn = [fromPath, key, nonce, toPath](ResultAsyncWorker* worker) {
- char* c_res = chat_decrypt_file(fromPath.c_str(), key.c_str(), nonce.c_str(), toPath.c_str());
+ char* c_res = lib.chat_decrypt_file(fromPath.c_str(), key.c_str(), nonce.c_str(), toPath.c_str());
HandleCResult(worker, c_res, "chat_decrypt_file");
};
@@ -636,7 +786,7 @@ Value ChatDecryptFile(const CallbackInfo& args) {
}
Object Init(Env env, Object exports) {
- haskell_init();
+ exports.Set("load", Function::New(env, Load));
auto* receivers = new Receivers();
// Stopping all receivers before joining any bounds teardown by the longest in-flight receive.
env.AddCleanupHook([receivers]() {
diff --git a/packages/simplex-chat-nodejs/cpp/simplex.h b/packages/simplex-chat-nodejs/cpp/simplex.h
index ddc052946a..7c44313295 100644
--- a/packages/simplex-chat-nodejs/cpp/simplex.h
+++ b/packages/simplex-chat-nodejs/cpp/simplex.h
@@ -9,40 +9,30 @@
#ifndef SimpleX_h
#define SimpleX_h
-extern "C" void hs_init(int argc, char **argv[]);
-extern "C" void hs_init_with_rtsopts(int * argc, char **argv[]);
-extern "C" void hs_thread_done(void);
-
typedef long* chat_ctrl;
+typedef void (*hs_init_with_rtsopts_fn)(int *argc, char **argv[]);
+typedef void (*hs_thread_done_fn)(void);
// the last parameter is used to return the pointer to chat controller
-extern "C" char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
-extern "C" char *chat_migrate_init_queue(const char *path, const char *key, const char *confirm, const int queueSize, chat_ctrl *ctrl);
-extern "C" char *chat_close_store(chat_ctrl ctrl);
-extern "C" char *chat_reopen_store(chat_ctrl ctrl);
-extern "C" char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
-extern "C" char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
-extern "C" char *chat_parse_markdown(const char *str);
-extern "C" char *chat_parse_server(const char *str);
-extern "C" char *chat_password_hash(const char *pwd, const char *salt);
-extern "C" char *chat_valid_name(const char *name);
-extern "C" int chat_json_length(const char *str);
-extern "C" char *chat_encrypt_media(chat_ctrl ctrl, const char *key, const char *frame, const int len);
-extern "C" char *chat_decrypt_media(const char *key, const char *frame, const int len);
+typedef char *(*chat_migrate_init_fn)(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
+typedef char *(*chat_migrate_init_queue_fn)(const char *path, const char *key, const char *confirm, int queueSize, chat_ctrl *ctrl);
+typedef char *(*chat_close_store_fn)(chat_ctrl ctrl);
+typedef char *(*chat_send_cmd_fn)(chat_ctrl ctrl, const char *cmd);
+typedef char *(*chat_recv_msg_wait_fn)(chat_ctrl ctrl, int wait);
// chat_write_file returns null-terminated string with JSON of WriteFileResult
-extern "C" char *chat_write_file(chat_ctrl ctrl, const char *path, const char *data, const int len);
+typedef char *(*chat_write_file_fn)(chat_ctrl ctrl, const char *path, const char *data, int len);
// chat_read_file returns a buffer with:
// result status (1 byte), then if
// status == 0 (success): buffer length (uint32, 4 bytes), buffer of specified length.
// status == 1 (error): null-terminated error message string.
-extern "C" char *chat_read_file(const char *path, const char *key, const char *nonce);
+typedef char *(*chat_read_file_fn)(const char *path, const char *key, const char *nonce);
// chat_encrypt_file returns null-terminated string with JSON of WriteFileResult
-extern "C" char *chat_encrypt_file(chat_ctrl ctrl, const char *fromPath, const char *toPath);
+typedef char *(*chat_encrypt_file_fn)(chat_ctrl ctrl, const char *fromPath, const char *toPath);
// chat_decrypt_file returns null-terminated string with the error message
-extern "C" char *chat_decrypt_file(const char *fromPath, const char *key, const char *nonce, const char *toPath);
+typedef char *(*chat_decrypt_file_fn)(const char *fromPath, const char *key, const char *nonce, const char *toPath);
#endif /* simplex_h */
\ No newline at end of file
diff --git a/packages/simplex-chat-nodejs/docs/Namespace.core.md b/packages/simplex-chat-nodejs/docs/Namespace.core.md
index 82b0d9ffba..fdf9f8b4dd 100644
--- a/packages/simplex-chat-nodejs/docs/Namespace.core.md
+++ b/packages/simplex-chat-nodejs/docs/Namespace.core.md
@@ -32,6 +32,7 @@ You are unlikely to ever need to use this module directly.
## Type Aliases
+- [Backend](core.TypeAlias.Backend.md)
- [DBMigrationError](core.TypeAlias.DBMigrationError.md)
- [MigrationError](core.TypeAlias.MigrationError.md)
- [MTRError](core.TypeAlias.MTRError.md)
@@ -46,3 +47,4 @@ You are unlikely to ever need to use this module directly.
- [chatRecvMsgWait](core.Function.chatRecvMsgWait.md)
- [chatSendCmd](core.Function.chatSendCmd.md)
- [chatWriteFile](core.Function.chatWriteFile.md)
+- [loadLibrary](core.Function.loadLibrary.md)
diff --git a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md
index 287099906b..cf293f55d9 100644
--- a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md
+++ b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md
@@ -6,7 +6,7 @@
# Class: ChatApi
-Defined in: [src/api.ts:97](../src/api.ts#L97)
+Defined in: [src/api.ts:95](../src/api.ts#L95)
Main API class for interacting with the chat core library.
@@ -16,7 +16,7 @@ Main API class for interacting with the chat core library.
> `protected` **ctrl\_**: `bigint` \| `undefined`
-Defined in: [src/api.ts:103](../src/api.ts#L103)
+Defined in: [src/api.ts:101](../src/api.ts#L101)
## Accessors
@@ -26,7 +26,7 @@ Defined in: [src/api.ts:103](../src/api.ts#L103)
> **get** **ctrl**(): `bigint`
-Defined in: [src/api.ts:344](../src/api.ts#L344)
+Defined in: [src/api.ts:343](../src/api.ts#L343)
Chat controller reference
@@ -42,7 +42,7 @@ Chat controller reference
> **get** **initialized**(): `boolean`
-Defined in: [src/api.ts:330](../src/api.ts#L330)
+Defined in: [src/api.ts:329](../src/api.ts#L329)
Chat controller is initialized
@@ -58,7 +58,7 @@ Chat controller is initialized
> **get** **started**(): `boolean`
-Defined in: [src/api.ts:337](../src/api.ts#L337)
+Defined in: [src/api.ts:336](../src/api.ts#L336)
Chat controller is started
@@ -72,7 +72,7 @@ Chat controller is started
> **apiAcceptContactRequest**(`contactReqId`): `Promise`\<`Contact`\>
-Defined in: [src/api.ts:750](../src/api.ts#L750)
+Defined in: [src/api.ts:770](../src/api.ts#L770)
Accept contact request.
Network usage: interactive.
@@ -93,7 +93,7 @@ Network usage: interactive.
> **apiAcceptMember**(`groupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\>
-Defined in: [src/api.ts:570](../src/api.ts#L570)
+Defined in: [src/api.ts:590](../src/api.ts#L590)
Accept group member. Requires Admin role.
Network usage: background.
@@ -122,7 +122,7 @@ Network usage: background.
> **apiAddMember**(`groupId`, `contactId`, `memberRole`): `Promise`\<`GroupMember`\>
-Defined in: [src/api.ts:550](../src/api.ts#L550)
+Defined in: [src/api.ts:560](../src/api.ts#L560)
Add contact to group. Requires bot to have Admin role.
Network usage: interactive.
@@ -147,11 +147,44 @@ Network usage: interactive.
***
+### apiAddMemberViaGroup()
+
+> **apiAddMemberViaGroup**(`groupId`, `viaGroupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\>
+
+Defined in: [src/api.ts:570](../src/api.ts#L570)
+
+Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group.
+Network usage: interactive.
+
+#### Parameters
+
+##### groupId
+
+`number`
+
+##### viaGroupId
+
+`number`
+
+##### groupMemberId
+
+`number`
+
+##### memberRole
+
+`GroupMemberRole`
+
+#### Returns
+
+`Promise`\<`GroupMember`\>
+
+***
+
### apiBlockMembersForAll()
> **apiBlockMembersForAll**(`groupId`, `groupMemberIds`, `blocked`): `Promise`\<`void`\>
-Defined in: [src/api.ts:590](../src/api.ts#L590)
+Defined in: [src/api.ts:610](../src/api.ts#L610)
Block members. Requires Moderator role.
Network usage: background.
@@ -180,7 +213,7 @@ Network usage: background.
> **apiCancelFile**(`fileId`): `Promise`\<`void`\>
-Defined in: [src/api.ts:540](../src/api.ts#L540)
+Defined in: [src/api.ts:550](../src/api.ts#L550)
Cancel file.
Network usage: background.
@@ -201,7 +234,7 @@ Network usage: background.
> **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ACIReaction`\>
-Defined in: [src/api.ts:513](../src/api.ts#L513)
+Defined in: [src/api.ts:523](../src/api.ts#L523)
Add/remove message reaction.
Network usage: background.
@@ -238,7 +271,7 @@ Network usage: background.
> **apiConnect**(`userId`, `incognito`, `preparedLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\>
-Defined in: [src/api.ts:719](../src/api.ts#L719)
+Defined in: [src/api.ts:739](../src/api.ts#L739)
Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link
Network usage: interactive.
@@ -267,7 +300,7 @@ Network usage: interactive.
> **apiConnectActiveUser**(`connLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\>
-Defined in: [src/api.ts:728](../src/api.ts#L728)
+Defined in: [src/api.ts:748](../src/api.ts#L748)
Connect via SimpleX link as string in the active user profile.
Network usage: interactive.
@@ -288,7 +321,7 @@ Network usage: interactive.
> **apiConnectPlan**(`userId`, `connectionLink`): `Promise`\<\[`ConnectionPlan`, `CreatedConnLink`\]\>
-Defined in: [src/api.ts:709](../src/api.ts#L709)
+Defined in: [src/api.ts:729](../src/api.ts#L729)
Determine SimpleX link type and if the bot is already connected via this link.
Network usage: interactive.
@@ -313,7 +346,7 @@ Network usage: interactive.
> **apiCreateActiveUser**(`profile?`): `Promise`\<`User`\>
-Defined in: [src/api.ts:887](../src/api.ts#L887)
+Defined in: [src/api.ts:907](../src/api.ts#L907)
Create new user profile
Network usage: no.
@@ -334,7 +367,7 @@ Network usage: no.
> **apiCreateGroupLink**(`groupId`, `memberRole`): `Promise`\<`string`\>
-Defined in: [src/api.ts:650](../src/api.ts#L650)
+Defined in: [src/api.ts:670](../src/api.ts#L670)
Create group link.
Network usage: interactive.
@@ -359,7 +392,7 @@ Network usage: interactive.
> **apiCreateLink**(`userId`): `Promise`\<`string`\>
-Defined in: [src/api.ts:696](../src/api.ts#L696)
+Defined in: [src/api.ts:716](../src/api.ts#L716)
Create 1-time invitation link.
Network usage: interactive.
@@ -380,7 +413,7 @@ Network usage: interactive.
> **apiCreateMemberContact**(`groupId`, `groupMemberId`): `Promise`\<`Contact`\>
-Defined in: [src/api.ts:953](../src/api.ts#L953)
+Defined in: [src/api.ts:973](../src/api.ts#L973)
Create a direct message contact with a group member.
Returns the created contact.
@@ -406,7 +439,7 @@ Network usage: interactive.
> **apiCreateUserAddress**(`userId`): `Promise`\<`CreatedConnLink`\>
-Defined in: [src/api.ts:361](../src/api.ts#L361)
+Defined in: [src/api.ts:360](../src/api.ts#L360)
Create bot address.
Network usage: interactive.
@@ -427,7 +460,7 @@ Network usage: interactive.
> **apiDeleteChat**(`chatType`, `chatId`, `deleteMode?`): `Promise`\<`void`\>
-Defined in: [src/api.ts:809](../src/api.ts#L809)
+Defined in: [src/api.ts:829](../src/api.ts#L829)
Delete chat.
Network usage: background.
@@ -456,7 +489,7 @@ Network usage: background.
> **apiDeleteChatItems**(`chatType`, `chatId`, `chatItemIds`, `deleteMode`): `Promise`\<`ChatItemDeletion`[]\>
-Defined in: [src/api.ts:488](../src/api.ts#L488)
+Defined in: [src/api.ts:498](../src/api.ts#L498)
Delete message.
Network usage: background.
@@ -489,7 +522,7 @@ Network usage: background.
> **apiDeleteGroupLink**(`groupId`): `Promise`\<`void`\>
-Defined in: [src/api.ts:672](../src/api.ts#L672)
+Defined in: [src/api.ts:692](../src/api.ts#L692)
Delete group link.
Network usage: background.
@@ -510,7 +543,7 @@ Network usage: background.
> **apiDeleteMemberChatItem**(`groupId`, `chatItemIds`): `Promise`\<`ChatItemDeletion`[]\>
-Defined in: [src/api.ts:503](../src/api.ts#L503)
+Defined in: [src/api.ts:513](../src/api.ts#L513)
Moderate message. Requires Moderator role (and higher than message author's).
Network usage: background.
@@ -535,7 +568,7 @@ Network usage: background.
> **apiDeleteUser**(`userId`, `delSMPQueues`, `viewPwd?`): `Promise`\<`void`\>
-Defined in: [src/api.ts:917](../src/api.ts#L917)
+Defined in: [src/api.ts:937](../src/api.ts#L937)
Delete user profile.
Network usage: background.
@@ -564,7 +597,7 @@ Network usage: background.
> **apiDeleteUserAddress**(`userId`): `Promise`\<`void`\>
-Defined in: [src/api.ts:371](../src/api.ts#L371)
+Defined in: [src/api.ts:370](../src/api.ts#L370)
Deletes a user address.
Network usage: background.
@@ -585,7 +618,7 @@ Network usage: background.
> **apiGetActiveUser**(): `Promise`\<`User` \| `undefined`\>
-Defined in: [src/api.ts:867](../src/api.ts#L867)
+Defined in: [src/api.ts:887](../src/api.ts#L887)
Get active user profile
Network usage: no.
@@ -600,7 +633,7 @@ Network usage: no.
> **apiGetChat**(`chatType`, `chatId`, `count`): `Promise`\<`any`\>
-Defined in: [src/api.ts:857](../src/api.ts#L857)
+Defined in: [src/api.ts:877](../src/api.ts#L877)
Get chat items.
Network usage: no.
@@ -629,7 +662,7 @@ Network usage: no.
> **apiGetChats**(`userId`, `pagination`, `query?`, `pendingConnections?`): `Promise`\<`AChat`[]\>
-Defined in: [src/api.ts:794](../src/api.ts#L794)
+Defined in: [src/api.ts:814](../src/api.ts#L814)
Get chat previews (paginated).
Network usage: no.
@@ -666,7 +699,7 @@ on large databases.
> **apiGetGroupLink**(`groupId`): `Promise`\<`GroupLink`\>
-Defined in: [src/api.ts:681](../src/api.ts#L681)
+Defined in: [src/api.ts:701](../src/api.ts#L701)
Get group link.
Network usage: no.
@@ -687,7 +720,7 @@ Network usage: no.
> **apiGetGroupLinkStr**(`groupId`): `Promise`\<`string`\>
-Defined in: [src/api.ts:687](../src/api.ts#L687)
+Defined in: [src/api.ts:707](../src/api.ts#L707)
#### Parameters
@@ -705,7 +738,7 @@ Defined in: [src/api.ts:687](../src/api.ts#L687)
> **apiGetUserAddress**(`userId`): `Promise`\<`UserContactLink` \| `undefined`\>
-Defined in: [src/api.ts:381](../src/api.ts#L381)
+Defined in: [src/api.ts:380](../src/api.ts#L380)
Get bot address and settings.
Network usage: no.
@@ -726,7 +759,7 @@ Network usage: no.
> **apiJoinGroup**(`groupId`): `Promise`\<`GroupInfo`\>
-Defined in: [src/api.ts:560](../src/api.ts#L560)
+Defined in: [src/api.ts:580](../src/api.ts#L580)
Join group.
Network usage: interactive.
@@ -747,7 +780,7 @@ Network usage: interactive.
> **apiLeaveGroup**(`groupId`): `Promise`\<`GroupInfo`\>
-Defined in: [src/api.ts:610](../src/api.ts#L610)
+Defined in: [src/api.ts:630](../src/api.ts#L630)
Leave group.
Network usage: background.
@@ -768,7 +801,7 @@ Network usage: background.
> **apiListContacts**(`userId`): `Promise`\<`Contact`[]\>
-Defined in: [src/api.ts:770](../src/api.ts#L770)
+Defined in: [src/api.ts:790](../src/api.ts#L790)
Get contacts.
Network usage: no.
@@ -789,7 +822,7 @@ Network usage: no.
> **apiListGroups**(`userId`, `contactId?`, `search?`): `Promise`\<`GroupInfo`[]\>
-Defined in: [src/api.ts:780](../src/api.ts#L780)
+Defined in: [src/api.ts:800](../src/api.ts#L800)
Get groups.
Network usage: no.
@@ -818,7 +851,7 @@ Network usage: no.
> **apiListMembers**(`groupId`): `Promise`\<`GroupMember`[]\>
-Defined in: [src/api.ts:620](../src/api.ts#L620)
+Defined in: [src/api.ts:640](../src/api.ts#L640)
Get group members.
Network usage: no.
@@ -839,7 +872,7 @@ Network usage: no.
> **apiListUsers**(): `Promise`\<`UserInfo`[]\>
-Defined in: [src/api.ts:897](../src/api.ts#L897)
+Defined in: [src/api.ts:917](../src/api.ts#L917)
Get all user profiles
Network usage: no.
@@ -854,7 +887,7 @@ Network usage: no.
> **apiNewGroup**(`userId`, `groupProfile`): `Promise`\<`GroupInfo`\>
-Defined in: [src/api.ts:630](../src/api.ts#L630)
+Defined in: [src/api.ts:650](../src/api.ts#L650)
Create group.
Network usage: no.
@@ -879,7 +912,7 @@ Network usage: no.
> **apiReceiveFile**(`fileId`): `Promise`\<`AChatItem`\>
-Defined in: [src/api.ts:529](../src/api.ts#L529)
+Defined in: [src/api.ts:539](../src/api.ts#L539)
Receive file.
Network usage: no.
@@ -900,7 +933,7 @@ Network usage: no.
> **apiRejectContactRequest**(`contactReqId`): `Promise`\<`void`\>
-Defined in: [src/api.ts:760](../src/api.ts#L760)
+Defined in: [src/api.ts:780](../src/api.ts#L780)
Reject contact request. The user who sent the request is **not notified**.
Network usage: no.
@@ -921,7 +954,7 @@ Network usage: no.
> **apiRemoveMembers**(`groupId`, `memberIds`, `withMessages?`): `Promise`\<`GroupMember`[]\>
-Defined in: [src/api.ts:600](../src/api.ts#L600)
+Defined in: [src/api.ts:620](../src/api.ts#L620)
Remove members. Requires Admin role.
Network usage: background.
@@ -950,7 +983,7 @@ Network usage: background.
> **apiSendMemberContactInvitation**(`contactId`, `message?`): `Promise`\<`Contact`\>
-Defined in: [src/api.ts:964](../src/api.ts#L964)
+Defined in: [src/api.ts:984](../src/api.ts#L984)
Send a direct message invitation to a group member contact.
The contact must have been created with [apiCreateMemberContact](#apicreatemembercontact).
@@ -976,7 +1009,7 @@ Network usage: interactive.
> **apiSendMessages**(`chat`, `messages`, `liveMessage?`): `Promise`\<`AChatItem`[]\>
-Defined in: [src/api.ts:432](../src/api.ts#L432)
+Defined in: [src/api.ts:442](../src/api.ts#L442)
Send messages.
Network usage: background.
@@ -1005,7 +1038,7 @@ Network usage: background.
> **apiSendTextMessage**(`chat`, `text`, `inReplyTo?`): `Promise`\<`AChatItem`[]\>
-Defined in: [src/api.ts:455](../src/api.ts#L455)
+Defined in: [src/api.ts:465](../src/api.ts#L465)
Send text message.
Network usage: background.
@@ -1034,7 +1067,7 @@ Network usage: background.
> **apiSendTextReply**(`chatItem`, `text`): `Promise`\<`AChatItem`[]\>
-Defined in: [src/api.ts:463](../src/api.ts#L463)
+Defined in: [src/api.ts:473](../src/api.ts#L473)
Send text message in reply to received message.
Network usage: background.
@@ -1059,7 +1092,7 @@ Network usage: background.
> **apiSetActiveUser**(`userId`, `viewPwd?`): `Promise`\<`User`\>
-Defined in: [src/api.ts:907](../src/api.ts#L907)
+Defined in: [src/api.ts:927](../src/api.ts#L927)
Set active user profile
Network usage: no.
@@ -1084,7 +1117,7 @@ Network usage: no.
> **apiSetAddressSettings**(`userId`, `__namedParameters`): `Promise`\<`void`\>
-Defined in: [src/api.ts:415](../src/api.ts#L415)
+Defined in: [src/api.ts:414](../src/api.ts#L414)
Set bot address settings.
Network usage: interactive.
@@ -1109,7 +1142,7 @@ Network usage: interactive.
> **apiSetAutoAcceptMemberContacts**(`userId`, `onOff`): `Promise`\<`void`\>
-Defined in: [src/api.ts:846](../src/api.ts#L846)
+Defined in: [src/api.ts:866](../src/api.ts#L866)
Set auto-accept member contacts.
Network usage: no.
@@ -1134,7 +1167,7 @@ Network usage: no.
> **apiSetContactCustomData**(`contactId`, `customData?`): `Promise`\<`void`\>
-Defined in: [src/api.ts:836](../src/api.ts#L836)
+Defined in: [src/api.ts:856](../src/api.ts#L856)
Set contact custom data.
Network usage: no.
@@ -1159,7 +1192,7 @@ Network usage: no.
> **apiSetContactPrefs**(`contactId`, `preferences`): `Promise`\<`void`\>
-Defined in: [src/api.ts:943](../src/api.ts#L943)
+Defined in: [src/api.ts:963](../src/api.ts#L963)
Configure chat preference overrides for the contact.
Network usage: background.
@@ -1184,7 +1217,7 @@ Network usage: background.
> **apiSetGroupCustomData**(`groupId`, `customData?`): `Promise`\<`void`\>
-Defined in: [src/api.ts:826](../src/api.ts#L826)
+Defined in: [src/api.ts:846](../src/api.ts#L846)
Set group custom data.
Network usage: no.
@@ -1209,7 +1242,7 @@ Network usage: no.
> **apiSetGroupLinkMemberRole**(`groupId`, `memberRole`): `Promise`\<`void`\>
-Defined in: [src/api.ts:663](../src/api.ts#L663)
+Defined in: [src/api.ts:683](../src/api.ts#L683)
Set member role for group link.
Network usage: no.
@@ -1234,7 +1267,7 @@ Network usage: no.
> **apiSetMembersRole**(`groupId`, `groupMemberIds`, `memberRole`): `Promise`\<`void`\>
-Defined in: [src/api.ts:580](../src/api.ts#L580)
+Defined in: [src/api.ts:600](../src/api.ts#L600)
Set members role. Requires Admin role.
Network usage: background.
@@ -1263,7 +1296,7 @@ Network usage: background.
> **apiSetProfileAddress**(`userId`, `enable`): `Promise`\<`UserProfileUpdateSummary`\>
-Defined in: [src/api.ts:399](../src/api.ts#L399)
+Defined in: [src/api.ts:398](../src/api.ts#L398)
Add address to bot profile.
Network usage: interactive.
@@ -1284,11 +1317,33 @@ Network usage: interactive.
***
+### apiSetUserDomain()
+
+> **apiSetUserDomain**(`userId`, `simplexDomain?`): `Promise`\<`User`\>
+
+Defined in: [src/api.ts:427](../src/api.ts#L427)
+
+#### Parameters
+
+##### userId
+
+`number`
+
+##### simplexDomain?
+
+`string`
+
+#### Returns
+
+`Promise`\<`User`\>
+
+***
+
### apiUpdateChatItem()
> **apiUpdateChatItem**(`chatType`, `chatId`, `chatItemId`, `msgContent`, `liveMessage`): `Promise`\<`ChatItem`\>
-Defined in: [src/api.ts:471](../src/api.ts#L471)
+Defined in: [src/api.ts:481](../src/api.ts#L481)
Update message.
Network usage: background.
@@ -1325,7 +1380,7 @@ Network usage: background.
> **apiUpdateGroupProfile**(`groupId`, `groupProfile`): `Promise`\<`GroupInfo`\>
-Defined in: [src/api.ts:640](../src/api.ts#L640)
+Defined in: [src/api.ts:660](../src/api.ts#L660)
Update group profile.
Network usage: background.
@@ -1350,7 +1405,7 @@ Network usage: background.
> **apiUpdateProfile**(`userId`, `profile`): `Promise`\<`UserProfileUpdateSummary` \| `undefined`\>
-Defined in: [src/api.ts:927](../src/api.ts#L927)
+Defined in: [src/api.ts:947](../src/api.ts#L947)
Update user profile.
Network usage: background.
@@ -1375,7 +1430,7 @@ Network usage: background.
> **close**(): `Promise`\<`void`\>
-Defined in: [src/api.ts:158](../src/api.ts#L158)
+Defined in: [src/api.ts:157](../src/api.ts#L157)
Stop chat controller and close chat database.
The database is not closed if stopping fails.
@@ -1391,7 +1446,7 @@ Usually doesn't need to be called in chat bots.
> **off**\<`K`\>(`event`, `subscriber?`): `void`
-Defined in: [src/api.ts:302](../src/api.ts#L302)
+Defined in: [src/api.ts:301](../src/api.ts#L301)
Unsubscribe all or a specific handler from a specific event.
@@ -1425,7 +1480,7 @@ An optional subscriber function for the event.
> **offAny**(`receiver?`): `void`
-Defined in: [src/api.ts:318](../src/api.ts#L318)
+Defined in: [src/api.ts:317](../src/api.ts#L317)
Unsubscribe all or a specific handler from any events.
@@ -1449,7 +1504,7 @@ An optional subscriber function for the event.
> **on**\<`K`\>(`subscribers`): `void`
-Defined in: [src/api.ts:212](../src/api.ts#L212)
+Defined in: [src/api.ts:211](../src/api.ts#L211)
Subscribe multiple event handlers at once.
@@ -1479,7 +1534,7 @@ If the same function is subscribed to event.
> **on**\<`K`\>(`event`, `subscriber`): `void`
-Defined in: [src/api.ts:220](../src/api.ts#L220)
+Defined in: [src/api.ts:219](../src/api.ts#L219)
Subscribe a handler to a specific event.
@@ -1517,7 +1572,7 @@ If the same function is subscribed to event.
> **onAny**(`receiver`): `void`
-Defined in: [src/api.ts:243](../src/api.ts#L243)
+Defined in: [src/api.ts:242](../src/api.ts#L242)
Subscribe a handler to any event.
@@ -1543,7 +1598,7 @@ If the same function is subscribed to event.
> **once**\<`K`\>(`event`, `subscriber`): `void`
-Defined in: [src/api.ts:254](../src/api.ts#L254)
+Defined in: [src/api.ts:253](../src/api.ts#L253)
Subscribe a handler to a specific event to be delivered one time.
@@ -1581,7 +1636,7 @@ If the same function is subscribed to event.
> **recvChatEvent**(`wait?`): `Promise`\<`ChatEvent` \| `undefined`\>
-Defined in: [src/api.ts:353](../src/api.ts#L353)
+Defined in: [src/api.ts:352](../src/api.ts#L352)
#### Parameters
@@ -1599,7 +1654,7 @@ Defined in: [src/api.ts:353](../src/api.ts#L353)
> **sendChatCmd**(`cmd`): `Promise`\<`ChatResponse`\>
-Defined in: [src/api.ts:349](../src/api.ts#L349)
+Defined in: [src/api.ts:348](../src/api.ts#L348)
#### Parameters
@@ -1617,7 +1672,7 @@ Defined in: [src/api.ts:349](../src/api.ts#L349)
> **startChat**(): `Promise`\<`void`\>
-Defined in: [src/api.ts:124](../src/api.ts#L124)
+Defined in: [src/api.ts:123](../src/api.ts#L123)
Start chat controller. Must be called with the existing user profile.
@@ -1631,7 +1686,7 @@ Start chat controller. Must be called with the existing user profile.
> **stopChat**(): `Promise`\<`void`\>
-Defined in: [src/api.ts:147](../src/api.ts#L147)
+Defined in: [src/api.ts:146](../src/api.ts#L146)
Stop chat controller.
`close` calls it before closing the database.
@@ -1649,7 +1704,7 @@ Usually doesn't need to be called in chat bots.
> **wait**\<`K`\>(`event`): `Promise`\<`ChatEvent` & `object`\>
-Defined in: [src/api.ts:262](../src/api.ts#L262)
+Defined in: [src/api.ts:261](../src/api.ts#L261)
Waits for specific event, with an optional predicate.
Returns `undefined` on timeout if specified.
@@ -1674,7 +1729,7 @@ Returns `undefined` on timeout if specified.
> **wait**\<`K`\>(`event`, `predicate`): `Promise`\<`ChatEvent` & `object`\>
-Defined in: [src/api.ts:263](../src/api.ts#L263)
+Defined in: [src/api.ts:262](../src/api.ts#L262)
Waits for specific event, with an optional predicate.
Returns `undefined` on timeout if specified.
@@ -1703,7 +1758,7 @@ Returns `undefined` on timeout if specified.
> **wait**\<`K`\>(`event`, `timeout`): `Promise`\
-Defined in: [src/api.ts:264](../src/api.ts#L264)
+Defined in: [src/api.ts:263](../src/api.ts#L263)
Waits for specific event, with an optional predicate.
Returns `undefined` on timeout if specified.
@@ -1732,7 +1787,7 @@ Returns `undefined` on timeout if specified.
> **wait**\<`K`\>(`event`, `predicate`, `timeout`): `Promise`\
-Defined in: [src/api.ts:265](../src/api.ts#L265)
+Defined in: [src/api.ts:264](../src/api.ts#L264)
Waits for specific event, with an optional predicate.
Returns `undefined` on timeout if specified.
@@ -1767,9 +1822,9 @@ Returns `undefined` on timeout if specified.
> `static` **init**(`db`, `confirm?`, `queueSize?`): `Promise`\<`ChatApi`\>
-Defined in: [src/api.ts:111](../src/api.ts#L111)
+Defined in: [src/api.ts:109](../src/api.ts#L109)
-Initializes the ChatApi.
+Initializes the ChatApi, loading libsimplex for `db.type` (downloaded on first use).
#### Parameters
diff --git a/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md b/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md
index 7fe255327c..26934a9c6b 100644
--- a/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md
+++ b/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md
@@ -8,12 +8,10 @@
> **DbConfig** = \{ `encryptionKey?`: `string`; `filePrefix`: `string`; `type`: `"sqlite"`; \} \| \{ `connectionString`: `string`; `schemaPrefix?`: `string`; `type`: `"postgres"`; \}
-Defined in: [src/api.ts:65](../src/api.ts#L65)
+Defined in: [src/api.ts:63](../src/api.ts#L63)
-Database configuration. The native library is built against exactly one
-backend (see `simplex_backend` / `SIMPLEX_BACKEND` at install time); this
-type makes the caller state which one they are targeting so field names
-can't lie about their meaning.
+Database configuration. `type` selects the libsimplex backend loaded by
+`ChatApi.init`; one backend per process.
## Union Members
diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.run.md b/packages/simplex-chat-nodejs/docs/bot.Function.run.md
index 0af2644bc2..bc31ad01a8 100644
--- a/packages/simplex-chat-nodejs/docs/bot.Function.run.md
+++ b/packages/simplex-chat-nodejs/docs/bot.Function.run.md
@@ -8,7 +8,7 @@
> **run**(`__namedParameters`): `Promise`\<\[[`ChatApi`](api.Class.ChatApi.md), `User`, `UserContactLink` \| `undefined`\]\>
-Defined in: [src/bot.ts:48](../src/bot.ts#L48)
+Defined in: [src/bot.ts:49](../src/bot.ts#L49)
## Parameters
diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md
index 8fbb699f80..0bcc49cbc6 100644
--- a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md
+++ b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md
@@ -8,7 +8,7 @@
> **subscribeChatItems**(`bot`, `onMessage`, `commands`): `void`
-Defined in: [src/bot.ts:108](../src/bot.ts#L108)
+Defined in: [src/bot.ts:110](../src/bot.ts#L110)
## Parameters
diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md
index 3f7757041b..036afe64d4 100644
--- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md
+++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md
@@ -14,7 +14,7 @@ Defined in: [src/bot.ts:36](../src/bot.ts#L36)
> **dbOpts**: [`BotDbOpts`](bot.TypeAlias.BotDbOpts.md)
-Defined in: [src/bot.ts:38](../src/bot.ts#L38)
+Defined in: [src/bot.ts:39](../src/bot.ts#L39)
***
@@ -22,7 +22,7 @@ Defined in: [src/bot.ts:38](../src/bot.ts#L38)
> `optional` **events?**: [`EventSubscribers`](api.TypeAlias.EventSubscribers.md)
-Defined in: [src/bot.ts:45](../src/bot.ts#L45)
+Defined in: [src/bot.ts:46](../src/bot.ts#L46)
***
@@ -30,7 +30,7 @@ Defined in: [src/bot.ts:45](../src/bot.ts#L45)
> `optional` **onCommands?**: `object`
-Defined in: [src/bot.ts:42](../src/bot.ts#L42)
+Defined in: [src/bot.ts:43](../src/bot.ts#L43)
#### Index Signature
@@ -42,7 +42,7 @@ Defined in: [src/bot.ts:42](../src/bot.ts#L42)
> `optional` **onMessage?**: (`chatItem`, `content`, `chat`) => `void` \| `Promise`\<`void`\>
-Defined in: [src/bot.ts:40](../src/bot.ts#L40)
+Defined in: [src/bot.ts:41](../src/bot.ts#L41)
#### Parameters
@@ -68,7 +68,7 @@ Defined in: [src/bot.ts:40](../src/bot.ts#L40)
> **options**: [`BotOptions`](bot.Interface.BotOptions.md)
-Defined in: [src/bot.ts:39](../src/bot.ts#L39)
+Defined in: [src/bot.ts:40](../src/bot.ts#L40)
***
@@ -77,3 +77,11 @@ Defined in: [src/bot.ts:39](../src/bot.ts#L39)
> **profile**: `Profile`
Defined in: [src/bot.ts:37](../src/bot.ts#L37)
+
+***
+
+### simplexDomain?
+
+> `optional` **simplexDomain?**: `string` \| `null`
+
+Defined in: [src/bot.ts:38](../src/bot.ts#L38)
diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md
index 3953d777da..64e3e764df 100644
--- a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md
+++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md
@@ -6,7 +6,7 @@
# Class: ChatAPIError
-Defined in: [src/core.ts:95](../src/core.ts#L95)
+Defined in: [src/core.ts:117](../src/core.ts#L117)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:95](../src/core.ts#L95)
> **new ChatAPIError**(`message`, `chatError?`): `ChatAPIError`
-Defined in: [src/core.ts:96](../src/core.ts#L96)
+Defined in: [src/core.ts:118](../src/core.ts#L118)
#### Parameters
@@ -44,7 +44,7 @@ Defined in: [src/core.ts:96](../src/core.ts#L96)
> **chatError**: `ChatError` \| `undefined` = `undefined`
-Defined in: [src/core.ts:96](../src/core.ts#L96)
+Defined in: [src/core.ts:118](../src/core.ts#L118)
***
@@ -52,7 +52,7 @@ Defined in: [src/core.ts:96](../src/core.ts#L96)
> **message**: `string`
-Defined in: [src/core.ts:96](../src/core.ts#L96)
+Defined in: [src/core.ts:118](../src/core.ts#L118)
#### Inherited from
diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md
index 649090e3ac..aefc4b9073 100644
--- a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md
+++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md
@@ -6,7 +6,7 @@
# Class: ChatInitError
-Defined in: [src/core.ts:119](../src/core.ts#L119)
+Defined in: [src/core.ts:141](../src/core.ts#L141)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:119](../src/core.ts#L119)
> **new ChatInitError**(`message`, `dbMigrationError`): `ChatInitError`
-Defined in: [src/core.ts:120](../src/core.ts#L120)
+Defined in: [src/core.ts:142](../src/core.ts#L142)
#### Parameters
@@ -44,7 +44,7 @@ Defined in: [src/core.ts:120](../src/core.ts#L120)
> **dbMigrationError**: [`DBMigrationError`](core.TypeAlias.DBMigrationError.md)
-Defined in: [src/core.ts:120](../src/core.ts#L120)
+Defined in: [src/core.ts:142](../src/core.ts#L142)
***
@@ -52,7 +52,7 @@ Defined in: [src/core.ts:120](../src/core.ts#L120)
> **message**: `string`
-Defined in: [src/core.ts:120](../src/core.ts#L120)
+Defined in: [src/core.ts:142](../src/core.ts#L142)
#### Inherited from
diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md
index fea7bfb531..9b6ad3c099 100644
--- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md
+++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md
@@ -6,7 +6,7 @@
# Interface: ErrorMigration
-Defined in: [src/core.ts:152](../src/core.ts#L152)
+Defined in: [src/core.ts:174](../src/core.ts#L174)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:152](../src/core.ts#L152)
> **dbFile**: `string`
-Defined in: [src/core.ts:154](../src/core.ts#L154)
+Defined in: [src/core.ts:176](../src/core.ts#L176)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:154](../src/core.ts#L154)
> **migrationError**: [`MigrationError`](core.TypeAlias.MigrationError.md)
-Defined in: [src/core.ts:155](../src/core.ts#L155)
+Defined in: [src/core.ts:177](../src/core.ts#L177)
***
@@ -34,7 +34,7 @@ Defined in: [src/core.ts:155](../src/core.ts#L155)
> **type**: `"errorMigration"`
-Defined in: [src/core.ts:153](../src/core.ts#L153)
+Defined in: [src/core.ts:175](../src/core.ts#L175)
#### Overrides
diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md
index eb0bfb11cb..d54f59003f 100644
--- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md
+++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md
@@ -6,7 +6,7 @@
# Interface: ErrorNotADatabase
-Defined in: [src/core.ts:147](../src/core.ts#L147)
+Defined in: [src/core.ts:169](../src/core.ts#L169)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:147](../src/core.ts#L147)
> **dbFile**: `string`
-Defined in: [src/core.ts:149](../src/core.ts#L149)
+Defined in: [src/core.ts:171](../src/core.ts#L171)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:149](../src/core.ts#L149)
> **type**: `"errorNotADatabase"`
-Defined in: [src/core.ts:148](../src/core.ts#L148)
+Defined in: [src/core.ts:170](../src/core.ts#L170)
#### Overrides
diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md
index ffaa364f9b..e76de932f6 100644
--- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md
+++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md
@@ -6,7 +6,7 @@
# Interface: ErrorSQL
-Defined in: [src/core.ts:158](../src/core.ts#L158)
+Defined in: [src/core.ts:180](../src/core.ts#L180)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:158](../src/core.ts#L158)
> **dbFile**: `string`
-Defined in: [src/core.ts:160](../src/core.ts#L160)
+Defined in: [src/core.ts:182](../src/core.ts#L182)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:160](../src/core.ts#L160)
> **migrationSQLError**: `string`
-Defined in: [src/core.ts:161](../src/core.ts#L161)
+Defined in: [src/core.ts:183](../src/core.ts#L183)
***
@@ -34,7 +34,7 @@ Defined in: [src/core.ts:161](../src/core.ts#L161)
> **type**: `"errorSQL"`
-Defined in: [src/core.ts:159](../src/core.ts#L159)
+Defined in: [src/core.ts:181](../src/core.ts#L181)
#### Overrides
diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md
index 67711030da..30157c808d 100644
--- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md
+++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md
@@ -6,7 +6,7 @@
# Interface: InvalidConfirmation
-Defined in: [src/core.ts:139](../src/core.ts#L139)
+Defined in: [src/core.ts:161](../src/core.ts#L161)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:139](../src/core.ts#L139)
> **type**: `"invalidConfirmation"`
-Defined in: [src/core.ts:140](../src/core.ts#L140)
+Defined in: [src/core.ts:162](../src/core.ts#L162)
#### Overrides
diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md
index 9093b5e9b1..5264e4977f 100644
--- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md
+++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md
@@ -6,7 +6,7 @@
# Interface: InvalidQueueSize
-Defined in: [src/core.ts:143](../src/core.ts#L143)
+Defined in: [src/core.ts:165](../src/core.ts#L165)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:143](../src/core.ts#L143)
> **type**: `"invalidQueueSize"`
-Defined in: [src/core.ts:144](../src/core.ts#L144)
+Defined in: [src/core.ts:166](../src/core.ts#L166)
#### Overrides
diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md
index b04f2f5af1..d94d5893ad 100644
--- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md
+++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md
@@ -8,4 +8,4 @@
> **Tag** = `"invalidConfirmation"` \| `"invalidQueueSize"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"`
-Defined in: [src/core.ts:133](../src/core.ts#L133)
+Defined in: [src/core.ts:155](../src/core.ts#L155)
diff --git a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md
index 48e250c7d4..cc2dea3901 100644
--- a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md
+++ b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md
@@ -6,7 +6,7 @@
# Enumeration: MigrationConfirmation
-Defined in: [src/core.ts:104](../src/core.ts#L104)
+Defined in: [src/core.ts:126](../src/core.ts#L126)
Migration confirmation mode
@@ -16,7 +16,7 @@ Migration confirmation mode
> **Console**: `"console"`
-Defined in: [src/core.ts:107](../src/core.ts#L107)
+Defined in: [src/core.ts:129](../src/core.ts#L129)
***
@@ -24,7 +24,7 @@ Defined in: [src/core.ts:107](../src/core.ts#L107)
> **Error**: `"error"`
-Defined in: [src/core.ts:108](../src/core.ts#L108)
+Defined in: [src/core.ts:130](../src/core.ts#L130)
***
@@ -32,7 +32,7 @@ Defined in: [src/core.ts:108](../src/core.ts#L108)
> **YesUp**: `"yesUp"`
-Defined in: [src/core.ts:105](../src/core.ts#L105)
+Defined in: [src/core.ts:127](../src/core.ts#L127)
***
@@ -40,4 +40,4 @@ Defined in: [src/core.ts:105](../src/core.ts#L105)
> **YesUpDown**: `"yesUpDown"`
-Defined in: [src/core.ts:106](../src/core.ts#L106)
+Defined in: [src/core.ts:128](../src/core.ts#L128)
diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md
index 4b0324b92c..69be845ed3 100644
--- a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md
+++ b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md
@@ -8,7 +8,7 @@
> **chatCloseStore**(`ctrl`): `Promise`\<`void`\>
-Defined in: [src/core.ts:20](../src/core.ts#L20)
+Defined in: [src/core.ts:42](../src/core.ts#L42)
Close chat store
diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md
index b6786fe1cb..a2a901ddc5 100644
--- a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md
+++ b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md
@@ -8,7 +8,7 @@
> **chatDecryptFile**(`fromPath`, `__namedParameters`, `toPath`): `Promise`\<`void`\>
-Defined in: [src/core.ts:76](../src/core.ts#L76)
+Defined in: [src/core.ts:98](../src/core.ts#L98)
Decrypt file
diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md
index 9e30e55a23..8f4c7815c4 100644
--- a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md
+++ b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md
@@ -8,7 +8,7 @@
> **chatEncryptFile**(`ctrl`, `fromPath`, `toPath`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\>
-Defined in: [src/core.ts:68](../src/core.ts#L68)
+Defined in: [src/core.ts:90](../src/core.ts#L90)
Encrypt file
diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md
index dbe4912520..c6ad146aa1 100644
--- a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md
+++ b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md
@@ -8,7 +8,7 @@
> **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`, `queueSize?`): `Promise`\<`bigint`\>
-Defined in: [src/core.ts:8](../src/core.ts#L8)
+Defined in: [src/core.ts:30](../src/core.ts#L30)
Initialize chat controller
diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md
index f6713f22d8..f9413c4d74 100644
--- a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md
+++ b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md
@@ -8,7 +8,7 @@
> **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\>
-Defined in: [src/core.ts:61](../src/core.ts#L61)
+Defined in: [src/core.ts:83](../src/core.ts#L83)
Read buffer from encrypted file
diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md
index 719cf610a1..8aaa2e1590 100644
--- a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md
+++ b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md
@@ -8,7 +8,7 @@
> **chatRecvMsgWait**(`ctrl`, `wait`): `Promise`\<`ChatEvent` \| `undefined`\>
-Defined in: [src/core.ts:40](../src/core.ts#L40)
+Defined in: [src/core.ts:62](../src/core.ts#L62)
Receive chat event
diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md
index 4296a714cf..9f8c34be0f 100644
--- a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md
+++ b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md
@@ -8,7 +8,7 @@
> **chatSendCmd**(`ctrl`, `cmd`): `Promise`\<`ChatResponse`\>
-Defined in: [src/core.ts:28](../src/core.ts#L28)
+Defined in: [src/core.ts:50](../src/core.ts#L50)
Send chat command as string
diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md
index 4ca640d8c4..bd22e9d2c6 100644
--- a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md
+++ b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md
@@ -8,7 +8,7 @@
> **chatWriteFile**(`ctrl`, `path`, `buffer`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\>
-Defined in: [src/core.ts:53](../src/core.ts#L53)
+Defined in: [src/core.ts:75](../src/core.ts#L75)
Write buffer to encrypted file
diff --git a/packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md b/packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md
new file mode 100644
index 0000000000..a2034b953d
--- /dev/null
+++ b/packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md
@@ -0,0 +1,24 @@
+[**simplex-chat**](README.md)
+
+***
+
+[simplex-chat](README.md) / [core](Namespace.core.md) / loadLibrary
+
+# Function: loadLibrary()
+
+> **loadLibrary**(`backend`): `Promise`\<`void`\>
+
+Defined in: [src/core.ts:13](../src/core.ts#L13)
+
+Resolve (downloading on first use) and load libsimplex for the backend.
+One libsimplex per process: the Haskell runtime is initialized once, and another backend is rejected.
+
+## Parameters
+
+### backend
+
+[`Backend`](core.TypeAlias.Backend.md)
+
+## Returns
+
+`Promise`\<`void`\>
diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md
index 2ede6d9ffb..63b0caba85 100644
--- a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md
+++ b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md
@@ -6,7 +6,7 @@
# Interface: APIResult\
-Defined in: [src/core.ts:90](../src/core.ts#L90)
+Defined in: [src/core.ts:112](../src/core.ts#L112)
## Type Parameters
@@ -20,7 +20,7 @@ Defined in: [src/core.ts:90](../src/core.ts#L90)
> `optional` **error?**: `ChatError`
-Defined in: [src/core.ts:92](../src/core.ts#L92)
+Defined in: [src/core.ts:114](../src/core.ts#L114)
***
@@ -28,4 +28,4 @@ Defined in: [src/core.ts:92](../src/core.ts#L92)
> `optional` **result?**: `R`
-Defined in: [src/core.ts:91](../src/core.ts#L91)
+Defined in: [src/core.ts:113](../src/core.ts#L113)
diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md
index 51a55b86b6..939b3f7430 100644
--- a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md
+++ b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md
@@ -6,7 +6,7 @@
# Interface: CryptoArgs
-Defined in: [src/core.ts:114](../src/core.ts#L114)
+Defined in: [src/core.ts:136](../src/core.ts#L136)
File encryption key and nonce
@@ -16,7 +16,7 @@ File encryption key and nonce
> **fileKey**: `string`
-Defined in: [src/core.ts:115](../src/core.ts#L115)
+Defined in: [src/core.ts:137](../src/core.ts#L137)
***
@@ -24,4 +24,4 @@ Defined in: [src/core.ts:115](../src/core.ts#L115)
> **fileNonce**: `string`
-Defined in: [src/core.ts:116](../src/core.ts#L116)
+Defined in: [src/core.ts:138](../src/core.ts#L138)
diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md
index 9e1fca49b4..30a7f0b060 100644
--- a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md
+++ b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md
@@ -6,7 +6,7 @@
# Interface: UpMigration
-Defined in: [src/core.ts:193](../src/core.ts#L193)
+Defined in: [src/core.ts:215](../src/core.ts#L215)
## Properties
@@ -14,7 +14,7 @@ Defined in: [src/core.ts:193](../src/core.ts#L193)
> **upName**: `string`
-Defined in: [src/core.ts:194](../src/core.ts#L194)
+Defined in: [src/core.ts:216](../src/core.ts#L216)
***
@@ -22,4 +22,4 @@ Defined in: [src/core.ts:194](../src/core.ts#L194)
> **withDown**: `boolean`
-Defined in: [src/core.ts:195](../src/core.ts#L195)
+Defined in: [src/core.ts:217](../src/core.ts#L217)
diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md
index f85fb492be..aaaa506f1e 100644
--- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md
+++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md
@@ -6,7 +6,7 @@
# Interface: MTREDifferent
-Defined in: [src/core.ts:214](../src/core.ts#L214)
+Defined in: [src/core.ts:236](../src/core.ts#L236)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:214](../src/core.ts#L214)
> **downMigrations**: `string`[]
-Defined in: [src/core.ts:216](../src/core.ts#L216)
+Defined in: [src/core.ts:238](../src/core.ts#L238)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:216](../src/core.ts#L216)
> **type**: `"different"`
-Defined in: [src/core.ts:215](../src/core.ts#L215)
+Defined in: [src/core.ts:237](../src/core.ts#L237)
#### Overrides
diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md
index f031ac8982..e088536be9 100644
--- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md
+++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md
@@ -6,7 +6,7 @@
# Interface: MTRENoDown
-Defined in: [src/core.ts:209](../src/core.ts#L209)
+Defined in: [src/core.ts:231](../src/core.ts#L231)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:209](../src/core.ts#L209)
> **dbMigrations**: `string`[]
-Defined in: [src/core.ts:211](../src/core.ts#L211)
+Defined in: [src/core.ts:233](../src/core.ts#L233)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:211](../src/core.ts#L211)
> **type**: `"noDown"`
-Defined in: [src/core.ts:210](../src/core.ts#L210)
+Defined in: [src/core.ts:232](../src/core.ts#L232)
#### Overrides
diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md
index 59501b539a..058c64d396 100644
--- a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md
+++ b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md
@@ -8,4 +8,4 @@
> **Tag** = `"noDown"` \| `"different"`
-Defined in: [src/core.ts:203](../src/core.ts#L203)
+Defined in: [src/core.ts:225](../src/core.ts#L225)
diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md
index f6202b9456..523fe8fe1e 100644
--- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md
+++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md
@@ -6,7 +6,7 @@
# Interface: MEDowngrade
-Defined in: [src/core.ts:182](../src/core.ts#L182)
+Defined in: [src/core.ts:204](../src/core.ts#L204)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:182](../src/core.ts#L182)
> **downMigrations**: `string`[]
-Defined in: [src/core.ts:184](../src/core.ts#L184)
+Defined in: [src/core.ts:206](../src/core.ts#L206)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:184](../src/core.ts#L184)
> **type**: `"downgrade"`
-Defined in: [src/core.ts:183](../src/core.ts#L183)
+Defined in: [src/core.ts:205](../src/core.ts#L205)
#### Overrides
diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md
index 7467895b74..c5856f1943 100644
--- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md
+++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md
@@ -6,7 +6,7 @@
# Interface: MEUpgrade
-Defined in: [src/core.ts:177](../src/core.ts#L177)
+Defined in: [src/core.ts:199](../src/core.ts#L199)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:177](../src/core.ts#L177)
> **type**: `"upgrade"`
-Defined in: [src/core.ts:178](../src/core.ts#L178)
+Defined in: [src/core.ts:200](../src/core.ts#L200)
#### Overrides
@@ -30,4 +30,4 @@ Defined in: [src/core.ts:178](../src/core.ts#L178)
> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md)[]
-Defined in: [src/core.ts:179](../src/core.ts#L179)
+Defined in: [src/core.ts:201](../src/core.ts#L201)
diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md
index 7b2546fcd5..f3f7d9bc1f 100644
--- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md
+++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md
@@ -6,7 +6,7 @@
# Interface: MigrationError
-Defined in: [src/core.ts:187](../src/core.ts#L187)
+Defined in: [src/core.ts:209](../src/core.ts#L209)
## Extends
@@ -18,7 +18,7 @@ Defined in: [src/core.ts:187](../src/core.ts#L187)
> **mtrError**: [`MTRError`](core.TypeAlias.MTRError.md)
-Defined in: [src/core.ts:189](../src/core.ts#L189)
+Defined in: [src/core.ts:211](../src/core.ts#L211)
***
@@ -26,7 +26,7 @@ Defined in: [src/core.ts:189](../src/core.ts#L189)
> **type**: `"migrationError"`
-Defined in: [src/core.ts:188](../src/core.ts#L188)
+Defined in: [src/core.ts:210](../src/core.ts#L210)
#### Overrides
diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md
index e2e1dcb33f..92f649e615 100644
--- a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md
+++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md
@@ -8,4 +8,4 @@
> **Tag** = `"upgrade"` \| `"downgrade"` \| `"migrationError"`
-Defined in: [src/core.ts:171](../src/core.ts#L171)
+Defined in: [src/core.ts:193](../src/core.ts#L193)
diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md
new file mode 100644
index 0000000000..9b32c20d5b
--- /dev/null
+++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md
@@ -0,0 +1,11 @@
+[**simplex-chat**](README.md)
+
+***
+
+[simplex-chat](README.md) / [core](Namespace.core.md) / Backend
+
+# Type Alias: Backend
+
+> **Backend** = `"sqlite"` \| `"postgres"`
+
+Defined in: [src/core.ts:5](../src/core.ts#L5)
diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md
index 3f4797fb8e..159ce87391 100644
--- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md
+++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md
@@ -8,4 +8,4 @@
> **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`InvalidQueueSize`](core.DBMigrationError.Interface.InvalidQueueSize.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md)
-Defined in: [src/core.ts:125](../src/core.ts#L125)
+Defined in: [src/core.ts:147](../src/core.ts#L147)
diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md
index 66d6a092dc..1c4ceb16e9 100644
--- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md
+++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md
@@ -8,4 +8,4 @@
> **MTRError** = [`MTRENoDown`](core.MTRError.Interface.MTRENoDown.md) \| [`MTREDifferent`](core.MTRError.Interface.MTREDifferent.md)
-Defined in: [src/core.ts:198](../src/core.ts#L198)
+Defined in: [src/core.ts:220](../src/core.ts#L220)
diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md
index 6c0ba77541..485311f0de 100644
--- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md
+++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md
@@ -8,4 +8,4 @@
> **MigrationError** = [`MEUpgrade`](core.MigrationError.Interface.MEUpgrade.md) \| [`MEDowngrade`](core.MigrationError.Interface.MEDowngrade.md) \| [`MigrationError`](core.MigrationError.Interface.MigrationError.md)
-Defined in: [src/core.ts:165](../src/core.ts#L165)
+Defined in: [src/core.ts:187](../src/core.ts#L187)
diff --git a/packages/simplex-chat-nodejs/jest.config.js b/packages/simplex-chat-nodejs/jest.config.js
index 18c5ddf0e5..ac94fe9001 100644
--- a/packages/simplex-chat-nodejs/jest.config.js
+++ b/packages/simplex-chat-nodejs/jest.config.js
@@ -1,6 +1,13 @@
+const path = require("path")
+
+// Tests load the locally built add-on instead of downloading the released one.
+process.env.SIMPLEX_ADDON_PATH ??= path.join(__dirname, "build", "Release", "simplex.node")
+
module.exports = {
preset: "ts-jest",
maxWorkers: 1,
+ // a test with a new database runs every migration, each committed with a disk flush: seconds on slow CI runners
+ testTimeout: 30000,
testEnvironment: "node",
transform: {
'^.+\\.ts$': ['ts-jest', {
diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json
index a482bafae8..67eefdee4a 100644
--- a/packages/simplex-chat-nodejs/package.json
+++ b/packages/simplex-chat-nodejs/package.json
@@ -1,22 +1,22 @@
{
"name": "simplex-chat",
- "version": "7.1.0-beta.6.2",
+ "version": "7.1.0-beta.6.3",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+ "gypfile": false,
+ "bin": {
+ "simplex-chat": "dist/cli.js"
+ },
"files": [
"src",
- "cpp",
"dist",
- "binding.gyp",
"tsconfig.json",
"docs",
"examples"
],
"scripts": {
- "preinstall": "node src/download-libs.js",
- "install": "node-gyp configure; node-gyp rebuild --release",
"install-tools": "npm install -g node-gyp",
- "configure": "node-gyp configure; mkdir libs 2> /dev/null | true",
+ "configure": "node-gyp configure",
"build": "node-gyp rebuild && tsc && cp ./src/simplex.* ./dist",
"run": "node src/index.js",
"build-run": "node-gyp build && node src/index.js",
@@ -26,13 +26,13 @@
"dependencies": {
"@simplex-chat/types": "^0.12.1",
"extract-zip": "^2.0.1",
- "fast-deep-equal": "^3.1.3",
- "node-addon-api": "^8.5.0"
+ "fast-deep-equal": "^3.1.3"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^25.0.5",
"jest": "^30.2.0",
+ "node-addon-api": "^8.5.0",
"ts-jest": "^29.4.6",
"typedoc": "^0.28.15",
"typedoc-plugin-markdown": "^4.9.0",
diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts
index 95d942ebf1..4ee4e4b3b9 100644
--- a/packages/simplex-chat-nodejs/src/api.ts
+++ b/packages/simplex-chat-nodejs/src/api.ts
@@ -57,10 +57,8 @@ interface EventSubscriber {
}
/**
- * Database configuration. The native library is built against exactly one
- * backend (see `simplex_backend` / `SIMPLEX_BACKEND` at install time); this
- * type makes the caller state which one they are targeting so field names
- * can't lie about their meaning.
+ * Database configuration. `type` selects the libsimplex backend loaded by
+ * `ChatApi.init`; one backend per process.
*/
export type DbConfig =
| {
@@ -108,7 +106,7 @@ export class ChatApi {
private constructor(protected ctrl_: bigint | undefined) {}
/**
- * Initializes the ChatApi.
+ * Initializes the ChatApi, loading libsimplex for `db.type` (downloaded on first use).
* @param {DbConfig} db - Database configuration (sqlite or postgres).
* @param {core.MigrationConfirmation} [confirm=core.MigrationConfirmation.YesUp] - Migration confirmation mode.
* @param {number} [queueSize] - Size of internal queues, the core default is used when omitted.
@@ -119,6 +117,7 @@ export class ChatApi {
queueSize?: number
): Promise {
const [path, key] = dbConfigToMigrateArgs(db)
+ await core.loadLibrary(db.type)
const ctrl = await core.chatMigrateInit(path, key, confirm, queueSize)
return new ChatApi(ctrl)
}
diff --git a/packages/simplex-chat-nodejs/src/cli.ts b/packages/simplex-chat-nodejs/src/cli.ts
new file mode 100644
index 0000000000..8f24e5a89d
--- /dev/null
+++ b/packages/simplex-chat-nodejs/src/cli.ts
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+import {parseArgs} from "util"
+import type {Backend} from "./core"
+import {resolveAddonPath, resolveLibsDir} from "./libs"
+
+const USAGE = "usage: simplex-chat install [--backend sqlite|postgres]"
+const EXIT_INSTALL_FAILED = 1
+const EXIT_USAGE = 2
+
+export function parseInstallArgs(argv: string[]): Backend {
+ const {positionals, values: {backend = "sqlite"}} = parseArgs({args: argv, allowPositionals: true, options: {backend: {type: "string"}}})
+ if (positionals.length !== 1 || positionals[0] !== "install") throw new Error("expected command: install")
+ if (backend !== "sqlite" && backend !== "postgres") throw new Error(`invalid backend: ${backend}`)
+ return backend
+}
+
+export async function main(argv: string[]): Promise {
+ if (argv.includes("-h") || argv.includes("--help")) {
+ console.log(USAGE)
+ return 0
+ }
+ let backend: Backend
+ try {
+ backend = parseInstallArgs(argv)
+ } catch (e) {
+ console.error(`${(e as Error).message}\n${USAGE}`)
+ return EXIT_USAGE
+ }
+ try {
+ const [dir, addon] = await Promise.all([resolveLibsDir(backend), resolveAddonPath()])
+ console.log(`libsimplex installed at: ${dir}`)
+ console.log(`Node.js add-on installed at: ${addon}`)
+ return 0
+ } catch (e) {
+ console.error(`install failed: ${(e as Error).message}`)
+ return EXIT_INSTALL_FAILED
+ }
+}
+
+if (require.main === module) {
+ main(process.argv.slice(2)).then(code => { process.exitCode = code })
+}
diff --git a/packages/simplex-chat-nodejs/src/core.ts b/packages/simplex-chat-nodejs/src/core.ts
index 49664fed71..5c246cf5c1 100644
--- a/packages/simplex-chat-nodejs/src/core.ts
+++ b/packages/simplex-chat-nodejs/src/core.ts
@@ -1,6 +1,28 @@
import {ChatEvent, ChatResponse, T} from "@simplex-chat/types"
+import * as libs from "./libs"
import * as simplex from "./simplex"
+export type Backend = "sqlite" | "postgres"
+
+let loading: {backend: Backend, promise: Promise} | undefined
+
+/**
+ * Resolve (downloading on first use) and load libsimplex for the backend.
+ * One libsimplex per process: the Haskell runtime is initialized once, and another backend is rejected.
+ */
+export function loadLibrary(backend: Backend): Promise {
+ if (loading) {
+ if (loading.backend === backend) return loading.promise
+ return Promise.reject(new Error(`libsimplex already loaded with backend=${loading.backend}; cannot switch to ${backend} in the same process`))
+ }
+ const promise = Promise.all([libs.resolveLibsDir(backend), libs.resolveAddonPath()])
+ .then(([dir, addon]) => simplex.load(addon, libs.libPath(dir)))
+ loading = {backend, promise}
+ // a failed download or load can be retried
+ promise.catch(() => { loading = undefined })
+ return promise
+}
+
/**
* Initialize chat controller
* @param {number} [queueSize] - Size of internal queues, the core default is used when omitted.
diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js
deleted file mode 100644
index fc4560fe81..0000000000
--- a/packages/simplex-chat-nodejs/src/download-libs.js
+++ /dev/null
@@ -1,266 +0,0 @@
-const https = require('https');
-const fs = require('fs');
-const path = require('path');
-const extract = require('extract-zip');
-
-const GITHUB_REPO = 'simplex-chat/simplex-chat-libs';
-const RELEASE_TAG = 'core-7.1.0.11';
-const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase();
-// A locally built libsimplex, copied into libs/ rather than loaded in place:
-// the addon's RUNPATH is $ORIGIN/../../libs.
-const LIBS_DIR_OVERRIDE = process.env.SIMPLEX_LIBS_DIR || process.env.npm_config_simplex_libs_dir;
-const LIB_NAMES = ['libsimplex.so', 'libsimplex.dylib', 'libsimplex.dll'];
-
-if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') {
- console.error(`✗ Invalid SIMPLEX_BACKEND: "${BACKEND}". Must be "sqlite" or "postgres".`);
- process.exit(1);
-}
-
-if (BACKEND === 'postgres' && (process.platform !== 'linux' || process.arch !== 'x64')) {
- console.error(`✗ SIMPLEX_BACKEND=postgres is only supported on Linux x86_64.`);
- process.exit(1);
-}
-
-const ROOT_DIR = process.cwd(); // Root of the package being installed
-const LIBS_DIR = path.join(ROOT_DIR, 'libs')
-const INSTALLED_FILE = path.join(LIBS_DIR, 'installed.txt');
-
-// Detect platform and architecture
-function getPlatformInfo() {
- const platform = process.platform;
- const arch = process.arch;
-
- let platformName;
- let archName;
-
- if (platform === 'linux') {
- platformName = 'linux';
- } else if (platform === 'darwin') {
- platformName = 'macos';
- } else if (platform === 'win32') {
- platformName = 'windows';
- } else {
- throw new Error(`Unsupported platform: ${platform}`);
- }
-
- if (arch === 'x64') {
- archName = 'x86_64';
- } else if (arch === 'arm64') {
- archName = 'aarch64';
- } else {
- throw new Error(`Unsupported architecture: ${arch}`);
- }
-
- return { platformName, archName };
-}
-
-// Cleanup on libs version mismatch
-function cleanLibsDirectory() {
- if (fs.existsSync(LIBS_DIR)) {
- console.log('Cleaning old libraries...');
- fs.rmSync(LIBS_DIR, { recursive: true, force: true });
- fs.mkdirSync(LIBS_DIR, { recursive: true });
- console.log('✓ Old libraries removed');
- }
-}
-
-// Check if libraries are already installed with the correct version
-function isAlreadyInstalled() {
- if (!fs.existsSync(INSTALLED_FILE)) {
- return false;
- }
-
- try {
- const installedVersion = fs.readFileSync(INSTALLED_FILE, 'utf-8').trim();
- const expectedVersion = `${RELEASE_TAG}:${BACKEND}`;
- if (installedVersion === expectedVersion) {
- console.log(`✓ Libraries version ${RELEASE_TAG}:${BACKEND} already installed`);
- return true;
- } else {
- console.log(`Version mismatch: installed ${installedVersion}, need ${expectedVersion}`);
- cleanLibsDirectory();
- return false;
- }
- } catch (err) {
- console.warn(`Could not read installed.txt: ${err.message}`);
- return false;
- }
-}
-
-// No version check: the files behind SIMPLEX_LIBS_DIR change on every rebuild.
-function installFromOverride() {
- if (!fs.existsSync(LIBS_DIR_OVERRIDE)) {
- throw new Error(`SIMPLEX_LIBS_DIR does not exist: ${LIBS_DIR_OVERRIDE}`);
- }
- const lib = LIB_NAMES.find((name) => fs.existsSync(path.join(LIBS_DIR_OVERRIDE, name)));
- if (!lib) {
- throw new Error(`No ${LIB_NAMES.join(' / ')} in SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`);
- }
- console.log(`Using libraries from SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`);
- cleanLibsDirectory();
- copyDirSync(LIBS_DIR_OVERRIDE, LIBS_DIR);
- // Not a release tag: a later install without the variable sees a mismatch
- // and replaces these files with the released ones.
- fs.writeFileSync(INSTALLED_FILE, `${LIBS_DIR_OVERRIDE}:${BACKEND}`, 'utf-8');
- console.log(`✓ Installed ${lib} and its runtime libraries from ${LIBS_DIR_OVERRIDE}`);
-}
-
-async function install() {
- try {
- if (LIBS_DIR_OVERRIDE) {
- installFromOverride();
- return;
- }
-
- // Check if already installed
- if (isAlreadyInstalled()) {
- return;
- }
-
- const { platformName, archName } = getPlatformInfo();
- const repoName = GITHUB_REPO.split('/')[1];
- const backendSuffix = BACKEND === 'postgres' ? '-postgres' : '';
- const zipFilename = `${repoName}-${platformName}-${archName}${backendSuffix}.zip`;
- const ZIP_URL = `https://github.com/${GITHUB_REPO}/releases/download/${RELEASE_TAG}/${zipFilename}`;
- const ZIP_PATH = path.join(ROOT_DIR, zipFilename);
- const TEMP_EXTRACT_DIR = path.join(ROOT_DIR, '.temp-extract');
-
- console.log(`Detected: ${platformName} ${archName}`);
- console.log(`Backend: ${BACKEND}`);
- console.log(`Downloading: ${zipFilename}`);
-
- // Create libs directory
- if (!fs.existsSync(LIBS_DIR)) {
- fs.mkdirSync(LIBS_DIR, { recursive: true });
- }
-
- // Download zip with error handling
- await downloadFile(ZIP_URL, ZIP_PATH);
-
- // Extract to temporary directory
- console.log('Extracting to temporary directory...');
- if (!fs.existsSync(TEMP_EXTRACT_DIR)) {
- fs.mkdirSync(TEMP_EXTRACT_DIR, { recursive: true });
- }
- await extract(ZIP_PATH, { dir: TEMP_EXTRACT_DIR });
-
- // Move libs folder contents to final location
- console.log('Moving libraries to libs/...');
- const libsSourcePath = path.join(TEMP_EXTRACT_DIR, 'libs');
-
- if (fs.existsSync(libsSourcePath)) {
- // Copy all files from libs folder to LIBS_DIR
- const files = fs.readdirSync(libsSourcePath);
- files.forEach(file => {
- const src = path.join(libsSourcePath, file);
- const dest = path.join(LIBS_DIR, file);
-
- if (fs.statSync(src).isDirectory()) {
- copyDirSync(src, dest);
- } else {
- fs.copyFileSync(src, dest);
- }
- });
- } else {
- throw new Error('libs folder not found in zip archive');
- }
-
- // Write installed.txt with version
- fs.writeFileSync(INSTALLED_FILE, `${RELEASE_TAG}:${BACKEND}`, 'utf-8');
- console.log(`✓ Wrote version ${RELEASE_TAG}:${BACKEND} to installed.txt`);
-
- // Cleanup
- fs.rmSync(TEMP_EXTRACT_DIR, { recursive: true, force: true });
- fs.unlinkSync(ZIP_PATH);
- console.log('✓ Installation complete');
- } catch (err) {
- console.error('✗ Failed:', err.message);
- process.exit(1);
- }
-}
-
-// Helper function to recursively copy directories
-function copyDirSync(src, dest) {
- if (!fs.existsSync(dest)) {
- fs.mkdirSync(dest, { recursive: true });
- }
- const files = fs.readdirSync(src);
- files.forEach(file => {
- const srcFile = path.join(src, file);
- const destFile = path.join(dest, file);
- if (fs.statSync(srcFile).isDirectory()) {
- copyDirSync(srcFile, destFile);
- } else {
- fs.copyFileSync(srcFile, destFile);
- }
- });
-}
-
-function downloadFile(url, dest) {
- return new Promise((resolve, reject) => {
- const file = fs.createWriteStream(dest);
-
- https.get(url, { headers: { 'User-Agent': 'Node.js' } }, (response) => {
- // Handle redirects
- if (response.statusCode === 302 || response.statusCode === 301) {
- file.destroy();
- fs.unlink(dest, () => {});
- return downloadFile(response.headers.location, dest)
- .then(resolve)
- .catch(reject);
- }
-
- // Handle 404
- if (response.statusCode === 404) {
- file.destroy();
- fs.unlink(dest, () => {});
- reject(new Error(
- `Release artifact not found (404). Check:\n` +
- ` - Repository exists: ${url.split('/releases')[0]}\n` +
- ` - Release tag exists: ${RELEASE_TAG}\n` +
- ` - Artifact filename is correct`
- ));
- return;
- }
-
- // Handle 403
- if (response.statusCode === 403) {
- file.destroy();
- fs.unlink(dest, () => {});
- reject(new Error(
- `Access denied (403). The repository may be private.\n` +
- `Set GITHUB_TOKEN environment variable for private repos.`
- ));
- return;
- }
-
- // Handle other HTTP errors
- if (response.statusCode < 200 || response.statusCode >= 300) {
- file.destroy();
- fs.unlink(dest, () => {});
- reject(new Error(
- `HTTP ${response.statusCode}: Failed to download from ${url}`
- ));
- return;
- }
-
- response.pipe(file);
-
- file.on('finish', () => {
- file.close();
- resolve();
- });
-
- file.on('error', (err) => {
- fs.unlink(dest, () => {});
- reject(new Error(`File write error: ${err.message}`));
- });
- }).on('error', (err) => {
- file.destroy();
- fs.unlink(dest, () => {});
- reject(new Error(`Download error: ${err.message}`));
- });
- });
-}
-
-install();
diff --git a/packages/simplex-chat-nodejs/src/libs.ts b/packages/simplex-chat-nodejs/src/libs.ts
new file mode 100644
index 0000000000..013e49f5d0
--- /dev/null
+++ b/packages/simplex-chat-nodejs/src/libs.ts
@@ -0,0 +1,202 @@
+import * as crypto from "crypto"
+import * as fs from "fs"
+import * as http from "http"
+import * as https from "https"
+import * as os from "os"
+import * as path from "path"
+import {pipeline} from "stream/promises"
+import extract = require("extract-zip")
+import type {Backend} from "./core"
+import {ADDON_RELEASE, LIBS_RELEASE, SHA256} from "./release"
+
+const GITHUB_REPO = "simplex-chat/simplex-chat-libs"
+const REQUEST_TIMEOUT_MS = 60_000
+export const MAX_REDIRECTS = 5
+const PLATFORMS: {[platform: string]: {name: string, lib: string} | undefined} = {
+ linux: {name: "linux", lib: "libsimplex.so"},
+ darwin: {name: "macos", lib: "libsimplex.dylib"},
+ win32: {name: "windows", lib: "libsimplex.dll"},
+}
+const ARCHS: {[arch: string]: string | undefined} = {x64: "x86_64", arm64: "aarch64"}
+const SUPPORTED = ["linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", "windows-x86_64"]
+
+function unsupported(platform: string, arch: string): Error {
+ return new Error(`Unsupported platform: ${platform}/${arch}; supported: ${SUPPORTED.join(", ")}`)
+}
+
+export function platformTag(platform: string, arch: string): string {
+ const tag = `${PLATFORMS[platform]?.name}-${ARCHS[arch]}`
+ if (!SUPPORTED.includes(tag)) throw unsupported(platform, arch)
+ return tag
+}
+
+function libName(platform: string = process.platform, arch: string = process.arch): string {
+ const lib = PLATFORMS[platform]?.lib
+ if (!lib) throw unsupported(platform, arch)
+ return lib
+}
+
+export function cacheRoot(platform: string, env: NodeJS.ProcessEnv, home: string = os.homedir()): string {
+ if (platform === "darwin") return path.join(home, "Library", "Caches", "simplex-chat")
+ if (platform === "win32") {
+ if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is not set")
+ return path.join(env.LOCALAPPDATA, "simplex-chat")
+ }
+ return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "simplex-chat")
+}
+
+export function libsAsset(backend: Backend, tag: string): string {
+ const suffix = backend === "postgres" ? "-postgres" : ""
+ return `simplex-chat-libs-${tag}${suffix}.zip`
+}
+
+export function addonAsset(tag: string): string {
+ return `simplex-chat-nodejs-${tag}.node`
+}
+
+export function releaseUrl(release: string, asset: string): string {
+ return `https://github.com/${GITHUB_REPO}/releases/download/${release}/${asset}`
+}
+
+function pinnedSha256(asset: string): string {
+ const sha256 = SHA256[asset]
+ if (!sha256) throw new Error(`no SHA-256 is pinned for ${asset}`)
+ return sha256
+}
+
+export function libPath(dir: string): string {
+ return path.join(dir, libName())
+}
+
+export async function resolveLibsDir(
+ backend: Backend,
+ env: NodeJS.ProcessEnv = process.env,
+ platform: string = process.platform,
+ arch: string = process.arch
+): Promise {
+ const lib = libName(platform, arch)
+ if (env.SIMPLEX_LIBS_DIR) {
+ const dir = path.resolve(env.SIMPLEX_LIBS_DIR)
+ if (!fs.existsSync(path.join(dir, lib))) throw new Error(`SIMPLEX_LIBS_DIR has no ${lib}: ${dir}`)
+ return dir
+ }
+ const tag = platformTag(platform, arch)
+ if (backend === "postgres" && tag !== "linux-x86_64") {
+ throw new Error(`postgres backend is only supported on linux-x86_64; current platform is ${tag}`)
+ }
+ const target = path.resolve(cacheRoot(platform, env), LIBS_RELEASE, backend)
+ if (!fs.existsSync(path.join(target, lib))) {
+ const asset = libsAsset(backend, tag)
+ await installLibs(releaseUrl(LIBS_RELEASE, asset), pinnedSha256(asset), target, lib)
+ }
+ return target
+}
+
+export async function resolveAddonPath(
+ env: NodeJS.ProcessEnv = process.env,
+ platform: string = process.platform,
+ arch: string = process.arch
+): Promise {
+ if (env.SIMPLEX_ADDON_PATH) {
+ const file = path.resolve(env.SIMPLEX_ADDON_PATH)
+ if (!fs.existsSync(file)) throw new Error(`SIMPLEX_ADDON_PATH does not exist: ${file}`)
+ return file
+ }
+ if (!ADDON_RELEASE) throw new Error("no prebuilt Node.js add-on is pinned in this version; set SIMPLEX_ADDON_PATH")
+ const asset = addonAsset(platformTag(platform, arch))
+ const target = path.resolve(cacheRoot(platform, env), ADDON_RELEASE, asset)
+ if (!fs.existsSync(target)) await installAddon(releaseUrl(ADDON_RELEASE, asset), pinnedSha256(asset), target)
+ return target
+}
+
+export function installLibs(url: string, sha256: string, target: string, lib: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise {
+ return inTempDir(target, async tmp => {
+ process.stderr.write(`Downloading libsimplex from ${url} ...\n`)
+ const zipPath = path.join(tmp, "libs.zip")
+ await download(url, zipPath, timeoutMs)
+ await verifySha256(zipPath, sha256, url)
+ await extract(zipPath, {dir: tmp})
+ const extracted = path.join(tmp, "libs")
+ if (!fs.existsSync(path.join(extracted, lib))) throw new Error(`libs/${lib} missing from ${url}`)
+ await renameInto(extracted, target, path.join(target, lib))
+ })
+}
+
+export function installAddon(url: string, sha256: string, target: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise {
+ return inTempDir(target, async tmp => {
+ process.stderr.write(`Downloading the Node.js add-on from ${url} ...\n`)
+ const file = path.join(tmp, path.basename(target))
+ await download(url, file, timeoutMs)
+ await verifySha256(file, sha256, url)
+ await renameInto(file, target, target)
+ })
+}
+
+async function verifySha256(file: string, expected: string, url: string): Promise {
+ const hash = crypto.createHash("sha256")
+ for await (const chunk of fs.createReadStream(file)) hash.update(chunk)
+ const actual = hash.digest("hex")
+ if (actual !== expected) throw new Error(`SHA-256 of ${url} is ${actual}, expected ${expected}`)
+}
+
+// The temp dir is next to the target, so the final rename stays on one filesystem.
+async function inTempDir(target: string, install: (tmp: string) => Promise): Promise {
+ const parent = path.dirname(target)
+ await fs.promises.mkdir(parent, {recursive: true})
+ const tmp = await fs.promises.mkdtemp(path.join(parent, ".download-"))
+ try {
+ await install(tmp)
+ } finally {
+ await fs.promises.rm(tmp, {recursive: true, force: true})
+ }
+}
+
+async function renameInto(from: string, target: string, installed: string): Promise {
+ try {
+ await fs.promises.rename(from, target)
+ } catch (e) {
+ // Another process installed the same version first; its files are identical.
+ // Windows reports renaming onto an existing directory or a loaded file as EPERM.
+ const code = (e as NodeJS.ErrnoException).code
+ const lost = (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") && fs.existsSync(target)
+ if (!lost) throw e
+ if (!fs.existsSync(installed)) {
+ throw new Error(`another process partially populated ${target} but ${path.basename(installed)} is missing; remove it manually and retry`)
+ }
+ }
+}
+
+function download(url: string, dest: string, timeoutMs: number, redirects = 0): Promise {
+ return new Promise((resolve, reject) => {
+ const get = url.startsWith("https:") ? https.get : http.get
+ // Once a response arrives, request errors are ignored: a download settles via pipeline, which
+ // waits for the file to close, so tmp cleanup cannot fail with EBUSY on Windows.
+ let res: http.IncomingMessage | undefined
+ const req = get(url, {headers: {"User-Agent": "simplex-chat-nodejs"}, timeout: timeoutMs}, response => {
+ res = response
+ const status = response.statusCode ?? 0
+ const location = response.headers.location
+ if (status >= 300 && status < 400 && location) {
+ response.resume()
+ if (redirects >= MAX_REDIRECTS) return reject(new Error(`too many redirects downloading ${url}`))
+ let next: URL
+ try {
+ next = new URL(location, url)
+ } catch (e) {
+ return reject(e)
+ }
+ if (next.protocol !== new URL(url).protocol) return reject(new Error(`redirect from ${url} to ${next} changes protocol`))
+ download(next.toString(), dest, timeoutMs, redirects + 1).then(resolve, reject)
+ return
+ }
+ if (status !== 200) {
+ response.resume()
+ return reject(new Error(`HTTP ${status} downloading ${url}`))
+ }
+ pipeline(response, fs.createWriteStream(dest)).then(resolve, reject)
+ })
+ const abort = (err: Error) => (res ? res.destroy(err) : req.destroy(err))
+ req.on("timeout", () => abort(new Error(`timeout downloading ${url}`)))
+ req.on("error", e => { if (!res) reject(e) })
+ })
+}
diff --git a/packages/simplex-chat-nodejs/src/release.ts b/packages/simplex-chat-nodejs/src/release.ts
new file mode 100644
index 0000000000..b56435e210
--- /dev/null
+++ b/packages/simplex-chat-nodejs/src/release.ts
@@ -0,0 +1,19 @@
+// Written by scripts/ci/packages_release.py from the simplex-chat-libs releases.
+
+export const LIBS_RELEASE = "core-7.1.0.11"
+
+export const ADDON_RELEASE: string | undefined = "nodejs-addon-308c0960c707"
+
+export const SHA256: {readonly [asset: string]: string} = {
+ "simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260",
+ "simplex-chat-libs-linux-x86_64-postgres.zip": "925b3166f969b12082ff8ee659e5f2a9933fe40120deb80c9aa3002a5ba6c803",
+ "simplex-chat-libs-linux-x86_64.zip": "dabad0824e8813ea4a084fbf37e1ba4cf6dcea9cd8ccadc521fec200f246208a",
+ "simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61",
+ "simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec",
+ "simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b",
+ "simplex-chat-nodejs-linux-aarch64.node": "3636a82c56c48229e0326fa03d6ceb5c16dd3afbcc1e86337783dc53a7328d67",
+ "simplex-chat-nodejs-linux-x86_64.node": "55f3479b03fdb57591179ed730da744f1aa362e7529df55221b60028415746e9",
+ "simplex-chat-nodejs-macos-aarch64.node": "a3472872fb0816f435f52ee5471088e6545ebffb95e89fbf8e1ab82275f2d56e",
+ "simplex-chat-nodejs-macos-x86_64.node": "290901f7f227019a8cee3acc832541c930c4926fc08110df25169810a9002f5a",
+ "simplex-chat-nodejs-windows-x86_64.node": "ec689373b4f0fef73253a8328f60b8e902a45ea67e756d42a1f759b44dfd7339",
+}
diff --git a/packages/simplex-chat-nodejs/src/simplex.d.ts b/packages/simplex-chat-nodejs/src/simplex.d.ts
index 1e0ca825a6..0187556afd 100644
--- a/packages/simplex-chat-nodejs/src/simplex.d.ts
+++ b/packages/simplex-chat-nodejs/src/simplex.d.ts
@@ -1,5 +1,6 @@
// These functions are defined in CPP add-on ../cpp/simplex.cc
+export function load(addonPath: string, libPath: string): void
export function chat_migrate_init(dbPath: string, dbKey: string, confirm: string): Promise<[bigint, string]>
export function chat_migrate_init_queue(dbPath: string, dbKey: string, confirm: string, queueSize: number): Promise<[bigint, string]>
export function chat_close_store(ctrl: bigint): Promise
diff --git a/packages/simplex-chat-nodejs/src/simplex.js b/packages/simplex-chat-nodejs/src/simplex.js
index fd26c67438..3bdfb528df 100644
--- a/packages/simplex-chat-nodejs/src/simplex.js
+++ b/packages/simplex-chat-nodejs/src/simplex.js
@@ -1 +1,22 @@
-module.exports = require("../build/Release/simplex")
+// The add-on is downloaded from the libsimplex release, so it is loaded at runtime by core.loadLibrary.
+const NOT_LOADED = "libsimplex is not loaded, call core.loadLibrary(backend) first"
+const BINDINGS = ["chat_migrate_init", "chat_migrate_init_queue", "chat_close_store", "chat_send_cmd", "chat_recv_msg_wait",
+ "chat_write_file", "chat_read_file", "chat_encrypt_file", "chat_decrypt_file"]
+
+let addon
+
+exports.load = (addonPath, libPath) => {
+ if (!addon) {
+ const addonModule = {exports: {}}
+ process.dlopen(addonModule, addonPath)
+ addon = addonModule.exports
+ }
+ addon.load(libPath)
+}
+
+for (const name of BINDINGS) {
+ exports[name] = (...args) => {
+ if (!addon) throw new Error(NOT_LOADED)
+ return addon[name](...args)
+ }
+}
diff --git a/packages/simplex-chat-nodejs/tests/api.unit.test.ts b/packages/simplex-chat-nodejs/tests/api.unit.test.ts
index 8bfecc3347..b4ae456af9 100644
--- a/packages/simplex-chat-nodejs/tests/api.unit.test.ts
+++ b/packages/simplex-chat-nodejs/tests/api.unit.test.ts
@@ -5,6 +5,7 @@ import * as core from "../src/core"
const user = {userId: 1} as T.User
async function chatWithResponse(response: object): Promise {
+ jest.spyOn(core, "loadLibrary").mockResolvedValue()
jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1))
jest.spyOn(core, "chatSendCmd").mockResolvedValue(response as ChatResponse)
return api.ChatApi.init({type: "sqlite", filePrefix: "unused"})
@@ -45,6 +46,7 @@ describe("documented success responses", () => {
describe("startChat lifecycle", () => {
function chatWithResponses(...responses: object[]): Promise {
+ jest.spyOn(core, "loadLibrary").mockResolvedValue()
jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1))
jest.spyOn(core, "chatRecvMsgWait").mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(undefined), 10)))
const send = jest.spyOn(core, "chatSendCmd")
@@ -126,3 +128,24 @@ describe("startChat lifecycle", () => {
expect(core.chatRecvMsgWait).toHaveBeenCalledWith(BigInt(1), 500_000)
})
})
+
+describe("ChatApi.init", () => {
+ it("loads the library for the configured backend before opening the database", async () => {
+ let loaded!: () => void
+ const load = jest.spyOn(core, "loadLibrary").mockReturnValue(new Promise(resolve => { loaded = resolve }))
+ const migrate = jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1))
+ const init = api.ChatApi.init({type: "postgres", connectionString: "postgres://unused"})
+ await new Promise(setImmediate)
+ expect(load).toHaveBeenCalledWith("postgres")
+ expect(migrate).not.toHaveBeenCalled()
+ loaded()
+ await init
+ expect(migrate).toHaveBeenCalled()
+ })
+
+ it("rejects an invalid config before loading the library", async () => {
+ const load = jest.spyOn(core, "loadLibrary").mockResolvedValue()
+ await expect(api.ChatApi.init({type: "mysql"} as unknown as api.DbConfig)).rejects.toThrow('Invalid DbConfig: {"type":"mysql"}')
+ expect(load).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/simplex-chat-nodejs/tests/core.test.ts b/packages/simplex-chat-nodejs/tests/core.test.ts
index 8eb106a5b5..608c3939cd 100644
--- a/packages/simplex-chat-nodejs/tests/core.test.ts
+++ b/packages/simplex-chat-nodejs/tests/core.test.ts
@@ -2,8 +2,19 @@ import {execFile, spawnSync} from "child_process";
import * as fs from "fs";
import * as path from "path";
import {core} from "../src/index";
+import * as libs from "../src/libs";
+import * as simplex from "../src/simplex";
describe("Core tests", () => {
+ let libPath: string;
+ let addonPath: string;
+ // the first run downloads libsimplex
+ beforeAll(async () => {
+ const libsDir = await libs.resolveLibsDir("sqlite");
+ libPath = libs.libPath(libsDir);
+ addonPath = await libs.resolveAddonPath();
+ await core.loadLibrary("sqlite");
+ }, 300000);
const tmpDir = "./tests/tmp";
const dbPath = path.join(tmpDir, "simplex_v1");
@@ -125,9 +136,10 @@ describe("Core tests", () => {
const start = Date.now();
await fs.promises.stat(tmpDir);
expect(Date.now() - start).toBeLessThan(200);
+ // one receiver thread per controller runs the 4 receives in turn
await Promise.all(receives);
await stopAndClose(ctrl);
- }, 10000);
+ }, 30000);
const itOnLinux = process.platform === "linux" ? it : it.skip;
@@ -157,7 +169,7 @@ describe("Core tests", () => {
expect(elapsed).toBeLessThan(1800);
await stopAndClose(ctrlA);
await stopAndClose(ctrlB);
- }, 10000);
+ });
it("should receive events of one controller in order", async () => {
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
@@ -171,7 +183,7 @@ describe("Core tests", () => {
{type: "timedAction", action: "second"}
]);
await stopAndClose(ctrl);
- }, 10000);
+ });
it("should close the store while a receive is in flight", async () => {
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
@@ -189,36 +201,70 @@ describe("Core tests", () => {
expect({closed, timerDelayBelow100ms: timerDelay < 100}).toEqual({closed: false, timerDelayBelow100ms: true});
await close;
expect(await receives).toEqual([{event: undefined}, {error: "chat receiver stopped"}]);
- }, 10000);
+ });
+
+ it("should accept loading libsimplex again from the same path", () => {
+ expect(() => simplex.load(addonPath, libPath)).not.toThrow();
+ });
+
+ it("should refuse to load libsimplex from another path", () => {
+ expect(() => simplex.load(addonPath, path.resolve("other", "libsimplex.so"))).toThrow(`libsimplex already loaded from ${libPath}`);
+ });
+
+ it("should load libsimplex after a failed load and in several workers", () => {
+ const addon = path.resolve(__dirname, "..", "build", "Release", "simplex.node");
+ const childDbPath = path.resolve(tmpDir, "simplex_workers");
+ const script = `
+ const {Worker} = require("worker_threads");
+ const simplex = require(${JSON.stringify(addon)});
+ try { simplex.load(${JSON.stringify(addon)}) } catch (e) { console.log(JSON.stringify(e.message.endsWith("does not export hs_init_with_rtsopts"))) }
+ const load = ${JSON.stringify(`require(${JSON.stringify(addon)}).load(${JSON.stringify(libPath)})`)};
+ const workers = Array.from({length: 4}, () => new Worker(load, {eval: true}));
+ Promise.all(workers.map((w) => new Promise((resolve, reject) => w.on("exit", resolve).on("error", reject))))
+ .then(async (codes) => {
+ simplex.load(${JSON.stringify(libPath)});
+ const [ctrl] = await simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp");
+ await simplex.chat_send_cmd(ctrl, "/_stop");
+ console.log(JSON.stringify({codes, closed: await simplex.chat_close_store(ctrl) === ""}));
+ });
+ `;
+ const child = spawnSync(process.execPath, ["-e", script], {timeout: 30000, encoding: "utf8"});
+ if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr);
+ expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()})
+ .toEqual({status: 0, signal: null, stdout: 'true\n{"codes":[0,0,0,0],"closed":true}'});
+ }, 35000);
it("should let the process exit while a receiver is idle", () => {
const childDbPath = path.resolve(tmpDir, "simplex_child");
const script = `
const simplex = require("./build/Release/simplex.node");
+ simplex.load(${JSON.stringify(libPath)});
simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp")
.then(([ctrl]) => simplex.chat_recv_msg_wait(ctrl, 1))
.then((res) => console.log("received " + JSON.stringify(res)));
`;
- const child = spawnSync(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 10000, encoding: "utf8"});
+ const child = spawnSync(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 25000, encoding: "utf8"});
if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr);
expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()})
.toEqual({status: 0, signal: null, stdout: 'received ""'});
- }, 15000);
+ });
it("should not crash when closing stopped controllers repeatedly", async () => {
const script = `
const fs = require("fs"), path = require("path");
const simplex = require("./build/Release/simplex.node");
+ simplex.load(${JSON.stringify(libPath)});
(async () => {
+ // a new database runs every migration, each committed with a disk flush that takes up to 30 ms on CI runners
+ const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-"));
for (let i = 0; i < 40; i++) {
- const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-"));
const [ctrl] = await simplex.chat_migrate_init(path.join(dir, "simplex"), "key", "yesUp");
await simplex.chat_send_cmd(ctrl, "/v");
await simplex.chat_send_cmd(ctrl, "/_stop");
const res = await simplex.chat_close_store(ctrl);
- fs.rmSync(dir, {recursive: true, force: true});
if (res !== "") throw new Error("close failed: " + res);
}
+ fs.rmSync(dir, {recursive: true, force: true});
})();
`;
const runChild = () => new Promise<{code: number | null, signal: NodeJS.Signals | null, stderr: string}>((resolve) => {
diff --git a/packages/simplex-chat-nodejs/tests/libs.test.ts b/packages/simplex-chat-nodejs/tests/libs.test.ts
new file mode 100644
index 0000000000..c8a65f6bdd
--- /dev/null
+++ b/packages/simplex-chat-nodejs/tests/libs.test.ts
@@ -0,0 +1,466 @@
+import * as fs from "fs"
+import * as http from "http"
+import * as https from "https"
+import * as os from "os"
+import * as path from "path"
+import {AddressInfo} from "net"
+import * as crypto from "crypto"
+import {addonAsset, cacheRoot, installAddon, installLibs, libPath, libsAsset, MAX_REDIRECTS, platformTag, releaseUrl, resolveAddonPath, resolveLibsDir} from "../src/libs"
+import * as release from "../src/release"
+import {main, parseInstallArgs} from "../src/cli"
+import * as libs from "../src/libs"
+import {storedZip} from "./zip"
+
+jest.mock("../src/release", () => ({LIBS_RELEASE: "core-test", ADDON_RELEASE: "nodejs-addon-test", SHA256: {}}))
+
+const LIB = "libtest.so"
+const ADDON = Buffer.from("addon")
+const PINNED = release.SHA256 as {[asset: string]: string}
+
+function sha256(data: Buffer): string {
+ return crypto.createHash("sha256").update(data).digest("hex")
+}
+const LINUX_LIB = "libsimplex.so"
+const {XDG_CACHE_HOME, LOCALAPPDATA, SIMPLEX_LIBS_DIR, SIMPLEX_ADDON_PATH} = process.env
+
+function setEnv(name: string, value: string | undefined): void {
+ if (value === undefined) delete process.env[name]
+ else process.env[name] = value
+}
+
+// A path resolution regression must neither download nor write into the real user cache.
+let home: string
+beforeEach(() => {
+ home = fs.mkdtempSync(path.join(os.tmpdir(), "libs-home-"))
+ jest.spyOn(os, "homedir").mockReturnValue(home)
+ jest.spyOn(https, "get").mockImplementation(() => { throw new Error("unexpected download") })
+ setEnv("XDG_CACHE_HOME", home)
+ setEnv("LOCALAPPDATA", home)
+ setEnv("SIMPLEX_LIBS_DIR", undefined)
+ setEnv("SIMPLEX_ADDON_PATH", undefined)
+})
+afterEach(() => {
+ jest.restoreAllMocks()
+ setEnv("XDG_CACHE_HOME", XDG_CACHE_HOME)
+ setEnv("LOCALAPPDATA", LOCALAPPDATA)
+ setEnv("SIMPLEX_LIBS_DIR", SIMPLEX_LIBS_DIR)
+ setEnv("SIMPLEX_ADDON_PATH", SIMPLEX_ADDON_PATH)
+ fs.rmSync(home, {recursive: true, force: true})
+})
+
+describe("paths", () => {
+ let libsDir: string
+
+ beforeEach(() => {
+ libsDir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-dir-"))
+ fs.writeFileSync(libPath(libsDir), "lib")
+ })
+ afterEach(() => fs.rmSync(libsDir, {recursive: true, force: true}))
+
+ it("uses the Python cache layout", () => {
+ expect(cacheRoot("linux", {XDG_CACHE_HOME: "/x"}, "/h")).toBe(path.join("/x", "simplex-chat"))
+ expect(cacheRoot("linux", {}, "/h")).toBe(path.join("/h", ".cache", "simplex-chat"))
+ expect(cacheRoot("darwin", {}, "/h")).toBe(path.join("/h", "Library", "Caches", "simplex-chat"))
+ expect(cacheRoot("win32", {LOCALAPPDATA: "C:\\L"}, "/h")).toBe(path.join("C:\\L", "simplex-chat"))
+ expect(() => cacheRoot("win32", {}, "/h")).toThrow("LOCALAPPDATA is not set")
+ })
+
+ it.each([
+ ["linux", "x64", "linux-x86_64"],
+ ["linux", "arm64", "linux-aarch64"],
+ ["darwin", "x64", "macos-x86_64"],
+ ["darwin", "arm64", "macos-aarch64"],
+ ["win32", "x64", "windows-x86_64"],
+ ])("tags %s/%s as %s", (platform, arch, tag) => {
+ expect(platformTag(platform, arch)).toBe(tag)
+ })
+
+ it("rejects unsupported platforms", () => {
+ expect(() => platformTag("win32", "arm64")).toThrow("Unsupported platform")
+ })
+
+ it("rejects an unsupported OS with its arch", async () => {
+ await expect(resolveLibsDir("sqlite", {}, "freebsd", "riscv64")).rejects.toThrow("Unsupported platform: freebsd/riscv64; supported: ")
+ })
+
+ it("uses the release URLs", () => {
+ const releases = "https://github.com/simplex-chat/simplex-chat-libs/releases/download"
+ expect(releaseUrl("core-7.1.0.11", libsAsset("sqlite", "macos-aarch64"))).toBe(`${releases}/core-7.1.0.11/simplex-chat-libs-macos-aarch64.zip`)
+ expect(releaseUrl("core-7.1.0.11", libsAsset("postgres", "linux-x86_64"))).toBe(`${releases}/core-7.1.0.11/simplex-chat-libs-linux-x86_64-postgres.zip`)
+ expect(releaseUrl("nodejs-addon-1", addonAsset("windows-x86_64"))).toBe(`${releases}/nodejs-addon-1/simplex-chat-nodejs-windows-x86_64.node`)
+ })
+
+ it("resolves an absolute SIMPLEX_LIBS_DIR", async () => {
+ await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir})).resolves.toBe(libsDir)
+ })
+
+ it("uses SIMPLEX_LIBS_DIR on an unsupported CPU", async () => {
+ fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib")
+ await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm")).resolves.toBe(libsDir)
+ })
+
+ it("uses SIMPLEX_LIBS_DIR for postgres outside linux-x86_64", async () => {
+ fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib")
+ await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm64")).resolves.toBe(libsDir)
+ })
+
+ it("returns a relative SIMPLEX_LIBS_DIR as absolute", async () => {
+ await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: path.relative(process.cwd(), libsDir)})).resolves.toBe(libsDir)
+ })
+
+ it.each([
+ ["linux", "x64", "libsimplex.so"],
+ ["darwin", "arm64", "libsimplex.dylib"],
+ ["win32", "x64", "libsimplex.dll"],
+ ])("looks for the %s library name", async (platform, arch, lib) => {
+ fs.rmSync(libPath(libsDir))
+ await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, platform, arch)).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${lib}: `)
+ })
+
+ it("returns a relative SIMPLEX_ADDON_PATH as absolute", async () => {
+ const addon = path.join(libsDir, "simplex.node")
+ fs.writeFileSync(addon, "addon")
+ await expect(resolveAddonPath({SIMPLEX_ADDON_PATH: path.relative(process.cwd(), addon)})).resolves.toBe(addon)
+ })
+
+ it("rejects a missing SIMPLEX_ADDON_PATH", async () => {
+ const addon = path.join(libsDir, "simplex.node")
+ await expect(resolveAddonPath({SIMPLEX_ADDON_PATH: addon})).rejects.toThrow(`SIMPLEX_ADDON_PATH does not exist: ${addon}`)
+ })
+
+ it("rejects a SIMPLEX_LIBS_DIR without libsimplex", async () => {
+ fs.rmSync(libPath(libsDir))
+ await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir})).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${path.basename(libPath(libsDir))}: ${libsDir}`)
+ })
+})
+
+describe("installLibs", () => {
+ let server: http.Server
+ let base: string
+ let tmp: string
+ const routes: {[p: string]: (res: http.ServerResponse) => void} = {}
+
+ beforeAll(async () => {
+ server = http.createServer((req, res) => {
+ const route = routes[req.url ?? ""]
+ if (route) route(res)
+ else res.writeHead(404).end()
+ })
+ await new Promise(resolve => server.listen(0, "127.0.0.1", resolve))
+ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
+ })
+
+ afterAll(() => new Promise(resolve => server.close(() => resolve())))
+
+ beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "libs-test-")) })
+ afterEach(() => fs.rmSync(tmp, {recursive: true, force: true}))
+
+ const good = storedZip({[`libs/${LIB}`]: "lib", "libs/libHSdep.so": "dep"})
+ const GOOD_SHA = sha256(good)
+ routes["/good.zip"] = res => res.writeHead(200).end(good)
+ routes["/redirect.zip"] = res => res.writeHead(302, {location: "/good.zip"}).end()
+ const nolib = storedZip({"other/file": "x"})
+ routes["/nolib.zip"] = res => res.writeHead(200).end(nolib)
+ routes["/stall.zip"] = res => { res.writeHead(200); res.write(good.subarray(0, 10)) }
+ let loopRequests = 0
+ routes["/loop.zip"] = res => { loopRequests++; res.writeHead(302, {location: "/loop.zip"}).end() }
+ routes["/to-http.zip"] = res => res.writeHead(302, {location: `${base}/good.zip`}).end()
+ routes["/bad-location.zip"] = res => res.writeHead(302, {location: "http://[bad"}).end()
+ routes["/no-location.zip"] = res => res.writeHead(302).end()
+ routes["/hang.zip"] = () => {}
+ routes["/cross-host.zip"] = res => res.writeHead(302, {location: "https://objects.libs.test/good.zip"}).end()
+ let downloadDirs: string[] = []
+ routes["/observed.zip"] = res => { downloadDirs = leftovers(); res.writeHead(200).end(good) }
+
+ function leftovers(): string[] {
+ return fs.readdirSync(tmp).filter(f => f.startsWith(".download-"))
+ }
+
+ // Serves https requests from a local http route (the requested path by default), recording the requested URLs.
+ function mockHttps(route?: string): string[] {
+ const requested: string[] = []
+ jest.spyOn(https, "get").mockImplementation(((url: string, options: http.RequestOptions, cb: (res: http.IncomingMessage) => void) => {
+ requested.push(url)
+ return http.get(`${base}${route ?? new URL(url).pathname}`, options, cb)
+ }) as unknown as typeof https.get)
+ return requested
+ }
+
+ function renameFails(code: string): void {
+ jest.spyOn(fs.promises, "rename").mockRejectedValue(Object.assign(new Error(code), {code}))
+ }
+
+ beforeEach(() => { jest.spyOn(console, "error").mockImplementation(() => {}) })
+
+ it("follows a redirect and installs libs/", async () => {
+ const target = path.join(tmp, "v1", "sqlite")
+ await installLibs(`${base}/redirect.zip`, GOOD_SHA, target, LIB)
+ expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib")
+ expect(fs.existsSync(path.join(target, "libHSdep.so"))).toBe(true)
+ })
+
+ it("downloads into a temp dir next to the target", async () => {
+ await installLibs(`${base}/observed.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)
+ expect(downloadDirs).toHaveLength(1)
+ expect(leftovers()).toEqual([])
+ })
+
+ it("stops following a redirect loop", async () => {
+ loopRequests = 0
+ await expect(installLibs(`${base}/loop.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("too many redirects")
+ expect(loopRequests).toBe(MAX_REDIRECTS + 1)
+ expect(leftovers()).toEqual([])
+ })
+
+ it("follows an https redirect to another host", async () => {
+ const requested = mockHttps()
+ const target = path.join(tmp, "sqlite")
+ await installLibs("https://libs.test/cross-host.zip", GOOD_SHA, target, LIB)
+ expect(requested).toEqual(["https://libs.test/cross-host.zip", "https://objects.libs.test/good.zip"])
+ expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib")
+ })
+
+ it("rejects a redirect from https to http", async () => {
+ mockHttps("/to-http.zip")
+ await expect(installLibs("https://libs.test/libs.zip", GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("changes protocol")
+ expect(leftovers()).toEqual([])
+ })
+
+ it("rejects an invalid redirect location", async () => {
+ await expect(installLibs(`${base}/bad-location.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("Invalid URL")
+ expect(leftovers()).toEqual([])
+ })
+
+ it("rejects a redirect without a location", async () => {
+ await expect(installLibs(`${base}/no-location.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("HTTP 302")
+ expect(leftovers()).toEqual([])
+ })
+
+ it("rejects 404 and cleans up", async () => {
+ const target = path.join(tmp, "sqlite")
+ await expect(installLibs(`${base}/missing.zip`, GOOD_SHA, target, LIB)).rejects.toThrow("HTTP 404")
+ expect(leftovers()).toEqual([])
+ expect(fs.existsSync(target)).toBe(false)
+ })
+
+ it("rejects a zip without the lib", async () => {
+ const target = path.join(tmp, "sqlite")
+ await expect(installLibs(`${base}/nolib.zip`, sha256(nolib), target, LIB)).rejects.toThrow(`libs/${LIB} missing`)
+ expect(leftovers()).toEqual([])
+ })
+
+ it("times out a request without a response", async () => {
+ await expect(installLibs(`${base}/hang.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB, 200)).rejects.toThrow("timeout")
+ expect(leftovers()).toEqual([])
+ })
+
+ it("times out a stalled download", async () => {
+ const target = path.join(tmp, "sqlite")
+ await expect(installLibs(`${base}/stall.zip`, GOOD_SHA, target, LIB, 200)).rejects.toThrow("timeout")
+ expect(leftovers()).toEqual([])
+ })
+
+ it.each(["sqlite", "postgres"] as const)("installs %s into a relative XDG_CACHE_HOME and returns an absolute path", async backend => {
+ const zip = storedZip({[`libs/${LINUX_LIB}`]: "lib"})
+ const asset = libsAsset(backend, "linux-x86_64")
+ PINNED[asset] = sha256(zip)
+ routes["/libsimplex.zip"] = res => res.writeHead(200).end(zip)
+ const requested = mockHttps("/libsimplex.zip")
+ const cache = path.relative(process.cwd(), path.join(tmp, "cache"))
+ const dir = await resolveLibsDir(backend, {XDG_CACHE_HOME: cache}, "linux", "x64")
+ expect(requested).toEqual([releaseUrl("core-test", asset)])
+ expect(path.isAbsolute(dir)).toBe(true)
+ expect(dir).toBe(path.join(tmp, "cache", "simplex-chat", "core-test", backend))
+ expect(fs.readFileSync(path.join(dir, LINUX_LIB), "utf8")).toBe("lib")
+ })
+
+ it("downloads the add-on into the cache", async () => {
+ routes["/addon.node"] = res => res.writeHead(200).end(ADDON)
+ PINNED[addonAsset("linux-x86_64")] = sha256(ADDON)
+ const requested = mockHttps("/addon.node")
+ const addon = await resolveAddonPath({XDG_CACHE_HOME: tmp}, "linux", "x64")
+ const version = path.join(tmp, "simplex-chat", "nodejs-addon-test")
+ expect(requested).toEqual([releaseUrl("nodejs-addon-test", addonAsset("linux-x86_64"))])
+ expect(addon).toBe(path.join(version, "simplex-chat-nodejs-linux-x86_64.node"))
+ expect(fs.readFileSync(addon, "utf8")).toBe("addon")
+ expect(fs.readdirSync(version)).toEqual(["simplex-chat-nodejs-linux-x86_64.node"])
+ })
+
+ it("does not download a cached add-on", async () => {
+ const requested = mockHttps("/addon.node")
+ const cached = path.join(cacheRoot("darwin", {}), "nodejs-addon-test", "simplex-chat-nodejs-macos-aarch64.node")
+ fs.mkdirSync(path.dirname(cached), {recursive: true})
+ fs.writeFileSync(cached, "addon")
+ await expect(resolveAddonPath({}, "darwin", "arm64")).resolves.toBe(cached)
+ expect(requested).toEqual([])
+ })
+
+ it("rejects a download with another SHA-256 and cleans up", async () => {
+ const target = path.join(tmp, "sqlite")
+ await expect(installLibs(`${base}/good.zip`, sha256(ADDON), target, LIB)).rejects.toThrow(`SHA-256 of ${base}/good.zip is ${GOOD_SHA}, expected ${sha256(ADDON)}`)
+ expect(fs.readdirSync(tmp)).toEqual([])
+ })
+
+ it("rejects an add-on with another SHA-256", async () => {
+ routes["/addon.node"] = res => res.writeHead(200).end(ADDON)
+ const target = path.join(tmp, "simplex.node")
+ await expect(installAddon(`${base}/addon.node`, GOOD_SHA, target)).rejects.toThrow("SHA-256 of")
+ expect(fs.readdirSync(tmp)).toEqual([])
+ })
+
+ it("rejects an asset without a pinned SHA-256", async () => {
+ mockHttps("/good.zip")
+ await expect(resolveLibsDir("sqlite", {XDG_CACHE_HOME: tmp}, "darwin", "x64"))
+ .rejects.toThrow("no SHA-256 is pinned for simplex-chat-libs-macos-x86_64.zip")
+ })
+
+ it("requires SIMPLEX_ADDON_PATH without a pinned add-on release", async () => {
+ jest.replaceProperty(release, "ADDON_RELEASE", undefined)
+ await expect(resolveAddonPath({XDG_CACHE_HOME: tmp}, "linux", "x64"))
+ .rejects.toThrow("no prebuilt Node.js add-on is pinned in this version; set SIMPLEX_ADDON_PATH")
+ })
+
+ it("rejects a missing add-on and cleans up", async () => {
+ const target = path.join(tmp, "simplex.node")
+ await expect(installAddon(`${base}/missing.node`, sha256(ADDON), target)).rejects.toThrow("HTTP 404")
+ expect(fs.readdirSync(tmp)).toEqual([])
+ })
+
+ it("accepts EPERM from rename onto an installed add-on", async () => {
+ routes["/addon.node"] = res => res.writeHead(200).end(ADDON)
+ const target = path.join(tmp, "simplex.node")
+ fs.writeFileSync(target, "addon")
+ renameFails("EPERM")
+ await expect(installAddon(`${base}/addon.node`, sha256(ADDON), target)).resolves.toBeUndefined()
+ expect(leftovers()).toEqual([])
+ })
+
+ it("does not download a cached lib", async () => {
+ const requested = mockHttps("/good.zip")
+ const cached = path.join(tmp, "simplex-chat", "core-test", "postgres")
+ fs.mkdirSync(cached, {recursive: true})
+ fs.writeFileSync(path.join(cached, LINUX_LIB), "lib")
+ await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, "linux", "x64")).resolves.toBe(cached)
+ expect(requested).toEqual([])
+ })
+
+ it("rejects a target populated without the lib", async () => {
+ const target = path.join(tmp, "sqlite")
+ fs.mkdirSync(target)
+ fs.writeFileSync(path.join(target, "other"), "x")
+ await expect(installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB)).rejects.toThrow(`another process partially populated ${target}`)
+ expect(leftovers()).toEqual([])
+ })
+
+ it.each([["linux", "arm64"], ["darwin", "arm64"]])("rejects postgres on %s/%s without downloading", async (platform, arch) => {
+ const requested = mockHttps("/good.zip")
+ await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, platform, arch)).rejects.toThrow("postgres backend is only supported on linux-x86_64")
+ expect(requested).toEqual([])
+ })
+
+ it.each(["EPERM", "EEXIST"])("accepts %s from rename onto an installed target", async code => {
+ const target = path.join(tmp, "sqlite")
+ fs.mkdirSync(target)
+ fs.writeFileSync(path.join(target, LIB), "lib")
+ renameFails(code)
+ await expect(installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB)).resolves.toBeUndefined()
+ expect(leftovers()).toEqual([])
+ })
+
+ it("rethrows EPERM from rename when the target does not exist", async () => {
+ renameFails("EPERM")
+ await expect(installLibs(`${base}/good.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("EPERM")
+ expect(leftovers()).toEqual([])
+ })
+
+ it("lets concurrent installs share one target", async () => {
+ const target = path.join(tmp, "sqlite")
+ await Promise.all([
+ installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB),
+ installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB),
+ ])
+ expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib")
+ expect(leftovers()).toEqual([])
+ })
+})
+
+describe("cli", () => {
+ function installWith(libsDir: string, addon: string): Promise {
+ setEnv("SIMPLEX_LIBS_DIR", libsDir)
+ setEnv("SIMPLEX_ADDON_PATH", addon)
+ return main(["install"])
+ }
+
+ it("parses install arguments", () => {
+ expect(parseInstallArgs(["install"])).toBe("sqlite")
+ expect(parseInstallArgs(["install", "--backend", "postgres"])).toBe("postgres")
+ expect(parseInstallArgs(["install", "--backend=postgres"])).toBe("postgres")
+ expect(() => parseInstallArgs(["install", "--backend", "mysql"])).toThrow("invalid backend: mysql")
+ expect(() => parseInstallArgs(["install", "--backend"])).toThrow("argument missing")
+ expect(() => parseInstallArgs(["install", "--force"])).toThrow("Unknown option '--force'")
+ expect(() => parseInstallArgs(["run"])).toThrow("expected command: install")
+ expect(() => parseInstallArgs(["install", "extra"])).toThrow("expected command: install")
+ expect(() => parseInstallArgs([])).toThrow("expected command: install")
+ })
+
+ it("prints the libs directory and the add-on", async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-"))
+ const addon = path.join(dir, "simplex.node")
+ fs.writeFileSync(libPath(dir), "lib")
+ fs.writeFileSync(addon, "addon")
+ const log = jest.spyOn(console, "log").mockImplementation(() => {})
+ try {
+ await expect(installWith(dir, addon)).resolves.toBe(0)
+ expect(log.mock.calls).toEqual([[`libsimplex installed at: ${dir}`], [`Node.js add-on installed at: ${addon}`]])
+ } finally {
+ fs.rmSync(dir, {recursive: true, force: true})
+ }
+ })
+
+ it("returns 1 without the add-on", async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-"))
+ fs.writeFileSync(libPath(dir), "lib")
+ const log = jest.spyOn(console, "log").mockImplementation(() => {})
+ const error = jest.spyOn(console, "error").mockImplementation(() => {})
+ try {
+ await expect(installWith(dir, path.join(dir, "simplex.node"))).resolves.toBe(1)
+ expect(log).not.toHaveBeenCalled()
+ expect(error).toHaveBeenCalledWith(expect.stringContaining("install failed: SIMPLEX_ADDON_PATH does not exist"))
+ } finally {
+ fs.rmSync(dir, {recursive: true, force: true})
+ }
+ })
+
+ it("returns 1 when install fails", async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-"))
+ const addon = path.join(dir, "simplex.node")
+ fs.writeFileSync(addon, "addon")
+ const error = jest.spyOn(console, "error").mockImplementation(() => {})
+ try {
+ await expect(installWith(dir, addon)).resolves.toBe(1)
+ expect(error).toHaveBeenCalledWith(expect.stringContaining("install failed: SIMPLEX_LIBS_DIR has no"))
+ } finally {
+ fs.rmSync(dir, {recursive: true, force: true})
+ }
+ })
+
+ it("installs the requested backend", async () => {
+ const resolve = jest.spyOn(libs, "resolveLibsDir").mockResolvedValue("/libs")
+ jest.spyOn(libs, "resolveAddonPath").mockResolvedValue("/addon.node")
+ jest.spyOn(console, "log").mockImplementation(() => {})
+ await expect(main(["install", "--backend", "postgres"])).resolves.toBe(0)
+ expect(resolve).toHaveBeenCalledWith("postgres")
+ })
+
+ it.each(["--help", "-h"])("prints usage on %s", async flag => {
+ const log = jest.spyOn(console, "log").mockImplementation(() => {})
+ await expect(main(["install", flag])).resolves.toBe(0)
+ expect(log).toHaveBeenCalledWith("usage: simplex-chat install [--backend sqlite|postgres]")
+ })
+
+ it("returns 2 with usage on invalid arguments", async () => {
+ const error = jest.spyOn(console, "error").mockImplementation(() => {})
+ await expect(main(["install", "--backend", "mysql"])).resolves.toBe(2)
+ expect(error).toHaveBeenCalledWith("invalid backend: mysql\nusage: simplex-chat install [--backend sqlite|postgres]")
+ })
+})
diff --git a/packages/simplex-chat-nodejs/tests/loader.test.ts b/packages/simplex-chat-nodejs/tests/loader.test.ts
new file mode 100644
index 0000000000..6a03fe8feb
--- /dev/null
+++ b/packages/simplex-chat-nodejs/tests/loader.test.ts
@@ -0,0 +1,107 @@
+import {spawnSync} from "child_process"
+import * as path from "path"
+
+jest.mock("../src/simplex", () => ({load: jest.fn()}))
+jest.mock("../src/libs", () => ({libPath: jest.requireActual("../src/libs").libPath, resolveLibsDir: jest.fn(), resolveAddonPath: jest.fn()}))
+
+const PACKAGE_DIR = path.join(__dirname, "..")
+const ADDON = path.join(PACKAGE_DIR, "build", "Release", "simplex.node")
+const CHILD_TIMEOUT_MS = 10000
+const LIBS_DIR = path.resolve("libs")
+const SWITCH_ERROR = "libsimplex already loaded with backend=sqlite; cannot switch to postgres in the same process"
+const ADDON_PATH = path.resolve("simplex.node")
+const {libPath} = jest.requireActual("../src/libs")
+
+function runAddon(script: string): string {
+ const r = spawnSync(process.execPath, ["-e", `const simplex = require(${JSON.stringify(ADDON)});\n${script}`], {encoding: "utf8", timeout: CHILD_TIMEOUT_MS})
+ return `${r.stdout}${r.stderr}status=${r.status} signal=${r.signal}`
+}
+
+function freshCore(): {core: typeof import("../src/core"), resolveLibsDir: jest.Mock, load: jest.Mock} {
+ let modules!: ReturnType
+ jest.isolateModules(() => {
+ const libs = require("../src/libs")
+ libs.resolveAddonPath.mockResolvedValue(ADDON_PATH)
+ modules = {
+ core: require("../src/core"),
+ resolveLibsDir: libs.resolveLibsDir,
+ load: require("../src/simplex").load,
+ }
+ })
+ return modules
+}
+
+describe("native load", () => {
+ it("requires load before every FFI call", () => {
+ const out = runAddon(`
+ for (const name of Object.keys(simplex).filter(n => n !== "load").sort()) {
+ try { simplex[name](); console.log(name + ": no error") } catch (e) { console.log(name + ": " + e.message) }
+ }`)
+ const bindings = ["chat_close_store", "chat_decrypt_file", "chat_encrypt_file", "chat_migrate_init", "chat_migrate_init_queue",
+ "chat_read_file", "chat_recv_msg_wait", "chat_send_cmd", "chat_write_file"]
+ const expected = bindings.map(name => `${name}: libsimplex is not loaded, call core.loadLibrary(backend) first\n`).join("")
+ expect(out).toBe(`${expected}status=0 signal=null`)
+ })
+
+ it("reports the path of a library that cannot be opened", () => {
+ const out = runAddon(`try { simplex.load("/nonexistent/libsimplex.so") } catch (e) { console.log(e.message) }`)
+ expect(out).toContain("cannot load /nonexistent/libsimplex.so")
+ })
+
+ it("rejects a non-string library path", () => {
+ const out = runAddon(`try { simplex.load(1) } catch (e) { console.log(e.name + ": " + e.message) }`)
+ expect(out).toContain("TypeError: Expected string (libPath)")
+ })
+
+ it("rejects a library without the chat exports", () => {
+ const out = runAddon(`try { simplex.load(${JSON.stringify(ADDON)}) } catch (e) { console.log(e.message) }`)
+ expect(out).toContain(`${ADDON} does not export hs_init_with_rtsopts`)
+ })
+})
+
+describe("add-on module", () => {
+ it("rejects FFI calls before the add-on is loaded", () => {
+ const simplex = jest.requireActual("../src/simplex")
+ expect(() => simplex.chat_send_cmd(BigInt(1), "/v")).toThrow("libsimplex is not loaded, call core.loadLibrary(backend) first")
+ })
+})
+
+describe("loadLibrary", () => {
+ it("shares one load between concurrent calls", async () => {
+ const {core, resolveLibsDir, load} = freshCore()
+ resolveLibsDir.mockResolvedValue(LIBS_DIR)
+ const first = core.loadLibrary("sqlite")
+ const second = core.loadLibrary("sqlite")
+ expect(second).toBe(first)
+ await Promise.all([first, second])
+ expect(resolveLibsDir).toHaveBeenCalledTimes(1)
+ expect(load.mock.calls).toEqual([[ADDON_PATH, libPath(LIBS_DIR)]])
+ })
+
+ it("refuses to switch backend while the first load is in progress", async () => {
+ const {core, resolveLibsDir} = freshCore()
+ resolveLibsDir.mockResolvedValue(LIBS_DIR)
+ const first = core.loadLibrary("sqlite")
+ await expect(core.loadLibrary("postgres")).rejects.toThrow(SWITCH_ERROR)
+ await first
+ })
+
+ it("keeps a completed load for the process", async () => {
+ const {core, resolveLibsDir, load} = freshCore()
+ resolveLibsDir.mockResolvedValue(LIBS_DIR)
+ await core.loadLibrary("sqlite")
+ await core.loadLibrary("sqlite")
+ await expect(core.loadLibrary("postgres")).rejects.toThrow(SWITCH_ERROR)
+ expect(resolveLibsDir).toHaveBeenCalledTimes(1)
+ expect(load).toHaveBeenCalledTimes(1)
+ })
+
+ it("retries after a failed load", async () => {
+ const {core, resolveLibsDir, load} = freshCore()
+ resolveLibsDir.mockRejectedValueOnce(new Error("HTTP 503")).mockResolvedValue(LIBS_DIR)
+ await expect(core.loadLibrary("sqlite")).rejects.toThrow("HTTP 503")
+ await expect(core.loadLibrary("postgres")).resolves.toBeUndefined()
+ expect(resolveLibsDir.mock.calls).toEqual([["sqlite"], ["postgres"]])
+ expect(load).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/packages/simplex-chat-nodejs/tests/zip.ts b/packages/simplex-chat-nodejs/tests/zip.ts
new file mode 100644
index 0000000000..547c8c211c
--- /dev/null
+++ b/packages/simplex-chat-nodejs/tests/zip.ts
@@ -0,0 +1,47 @@
+import * as zlib from "zlib"
+
+const LOCAL_FILE_HEADER = 0x04034b50
+const CENTRAL_DIRECTORY_HEADER = 0x02014b50
+const END_OF_CENTRAL_DIRECTORY = 0x06054b50
+const ZIP_VERSION = 20
+const LOCAL_HEADER_SIZE = 30
+const CENTRAL_ENTRY_SIZE = 46
+const END_RECORD_SIZE = 22
+
+export function storedZip(files: {[name: string]: string}): Buffer {
+ const local: Buffer[] = []
+ const central: Buffer[] = []
+ let offset = 0
+ for (const [name, content] of Object.entries(files)) {
+ const nameBuf = Buffer.from(name)
+ const data = Buffer.from(content)
+ const crc = zlib.crc32(data)
+ const header = Buffer.alloc(LOCAL_HEADER_SIZE)
+ header.writeUInt32LE(LOCAL_FILE_HEADER, 0)
+ header.writeUInt16LE(ZIP_VERSION, 4)
+ header.writeUInt32LE(crc, 14)
+ header.writeUInt32LE(data.length, 18)
+ header.writeUInt32LE(data.length, 22)
+ header.writeUInt16LE(nameBuf.length, 26)
+ local.push(header, nameBuf, data)
+ const entry = Buffer.alloc(CENTRAL_ENTRY_SIZE)
+ entry.writeUInt32LE(CENTRAL_DIRECTORY_HEADER, 0)
+ entry.writeUInt16LE(ZIP_VERSION, 4)
+ entry.writeUInt16LE(ZIP_VERSION, 6)
+ entry.writeUInt32LE(crc, 16)
+ entry.writeUInt32LE(data.length, 20)
+ entry.writeUInt32LE(data.length, 24)
+ entry.writeUInt16LE(nameBuf.length, 28)
+ entry.writeUInt32LE(offset, 42)
+ central.push(entry, nameBuf)
+ offset += header.length + nameBuf.length + data.length
+ }
+ const centralBuf = Buffer.concat(central)
+ const end = Buffer.alloc(END_RECORD_SIZE)
+ end.writeUInt32LE(END_OF_CENTRAL_DIRECTORY, 0)
+ end.writeUInt16LE(Object.keys(files).length, 8)
+ end.writeUInt16LE(Object.keys(files).length, 10)
+ end.writeUInt32LE(centralBuf.length, 12)
+ end.writeUInt32LE(offset, 16)
+ return Buffer.concat([...local, centralBuf, end])
+}
diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py
index 009ff9a1e0..009ec685f4 100644
--- a/packages/simplex-chat-python/src/simplex_chat/_native.py
+++ b/packages/simplex-chat-python/src/simplex_chat/_native.py
@@ -7,6 +7,7 @@ from __future__ import annotations
import ctypes
import errno
+import hashlib
import os
import platform
import sys
@@ -18,7 +19,7 @@ from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p
from pathlib import Path
from typing import Any, Literal
-from ._version import LIBS_VERSION
+from ._version import LIBS_RELEASE, LIBS_SHA256
Backend = Literal["sqlite", "postgres"]
@@ -59,11 +60,14 @@ def _libname() -> str:
return _LIBNAME[sys.platform]
-def _libs_url(backend: Backend) -> str:
+def _libs_asset(backend: Backend) -> str:
suffix = "-postgres" if backend == "postgres" else ""
+ return f"simplex-chat-libs-{_platform_tag()}{suffix}.zip"
+
+
+def _libs_url(backend: Backend) -> str:
return (
- f"https://github.com/{_GITHUB_REPO}/releases/download/"
- f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip"
+ f"https://github.com/{_GITHUB_REPO}/releases/download/{LIBS_RELEASE}/{_libs_asset(backend)}"
)
@@ -84,7 +88,7 @@ def _resolve_libs_dir(backend: Backend) -> Path:
"postgres backend is only supported on linux-x86_64; "
f"current platform is {_platform_tag()}"
)
- target = _cache_root() / f"v{LIBS_VERSION}" / backend
+ target = _cache_root() / LIBS_RELEASE / backend
if not (target / _libname()).exists():
_download(target, backend)
return target
@@ -125,16 +129,21 @@ def _download(target: Path, backend: Backend) -> None:
what we need and proceed. NEVER rmtree the target: that creates a TOCTOU
window where another process is reading/loading the file we're deleting.
"""
+ asset = _libs_asset(backend)
+ sha256 = LIBS_SHA256.get(asset)
+ if sha256 is None:
+ raise RuntimeError(f"no SHA-256 is pinned for {asset}")
target.parent.mkdir(parents=True, exist_ok=True)
url = _libs_url(backend)
print(
- f"Downloading libsimplex ({_platform_tag()}, {backend}) v{LIBS_VERSION} from {url} ...",
+ f"Downloading libsimplex ({_platform_tag()}, {backend}) {LIBS_RELEASE} from {url} ...",
file=sys.stderr,
flush=True,
)
with tempfile.TemporaryDirectory(dir=target.parent) as tmp:
zip_path = Path(tmp) / "libs.zip"
_stream_to_file(url, zip_path, timeout=60.0)
+ _verify_sha256(zip_path, sha256, url)
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(tmp)
# zip layout: /libs/libsimplex.* + libHS*.*
@@ -158,6 +167,16 @@ def _download(target: Path, backend: Backend) -> None:
) from e
+def _verify_sha256(file: Path, expected: str, url: str) -> None:
+ digest = hashlib.sha256()
+ with file.open("rb") as f:
+ while chunk := f.read(_DOWNLOAD_CHUNK):
+ digest.update(chunk)
+ actual = digest.hexdigest()
+ if actual != expected:
+ raise RuntimeError(f"SHA-256 of {url} is {actual}, expected {expected}")
+
+
_lock = threading.Lock()
_lib: ctypes.CDLL | None = None
_libc: ctypes.CDLL | None = None
@@ -230,8 +249,8 @@ def lib_for(backend: Backend) -> ctypes.CDLL:
if _lib is not None:
if _backend != backend:
raise RuntimeError(
- f"libsimplex already loaded with backend={_backend!r}; "
- f"cannot switch to {backend!r} in the same process"
+ f"libsimplex already loaded with backend={_backend}; "
+ f"cannot switch to {backend} in the same process"
)
return _lib
libs_dir = _resolve_libs_dir(backend)
diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py
index c6c24a1e59..0786a81795 100644
--- a/packages/simplex-chat-python/src/simplex_chat/_version.py
+++ b/packages/simplex-chat-python/src/simplex_chat/_version.py
@@ -1,9 +1,16 @@
-"""Single source of truth for both the Python package version and the
-simplex-chat-libs release tag we depend on.
+"""Package version and the simplex-chat-libs release it loads.
-Bump both together for normal releases. For wrapper-only fixes use a PEP 440
-post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged.
+Written by scripts/ci/packages_release.py: the release PR bumps the version
+and pins new library releases with the SHA-256 of their assets.
"""
__version__ = "7.1.0b6" # PEP 440 — read by hatchling for wheel metadata
-LIBS_VERSION = "7.1.0-beta.6" # simplex-chat-libs release tag (no 'v' prefix)
+LIBS_RELEASE = "core-7.1.0.11" # simplex-chat-libs release tag
+LIBS_SHA256 = {
+ "simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260",
+ "simplex-chat-libs-linux-x86_64-postgres.zip": "925b3166f969b12082ff8ee659e5f2a9933fe40120deb80c9aa3002a5ba6c803",
+ "simplex-chat-libs-linux-x86_64.zip": "dabad0824e8813ea4a084fbf37e1ba4cf6dcea9cd8ccadc521fec200f246208a",
+ "simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61",
+ "simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec",
+ "simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b",
+}
diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py
index 6c4f2a923d..ec10879077 100644
--- a/packages/simplex-chat-python/tests/test_native_cache.py
+++ b/packages/simplex-chat-python/tests/test_native_cache.py
@@ -1,10 +1,12 @@
+import hashlib
import zipfile
from pathlib import Path
import pytest
-from simplex_chat._native import _cache_root, _download, _resolve_libs_dir
-from simplex_chat._version import LIBS_VERSION
+from simplex_chat import _native
+from simplex_chat._native import _cache_root, _download, _resolve_libs_dir, lib_for
+from simplex_chat._version import LIBS_RELEASE
def test_cache_root_linux(tmp_path, monkeypatch):
@@ -42,7 +44,7 @@ def test_resolve_downloads_when_missing(tmp_path, monkeypatch):
monkeypatch.setattr("simplex_chat._native._download", fake_download)
libs_dir = _resolve_libs_dir("sqlite")
- assert libs_dir == tmp_path / "simplex-chat" / f"v{LIBS_VERSION}" / "sqlite"
+ assert libs_dir == tmp_path / "simplex-chat" / LIBS_RELEASE / "sqlite"
assert called["backend"] == "sqlite"
assert (libs_dir / "libsimplex.so").exists()
@@ -50,7 +52,7 @@ def test_resolve_downloads_when_missing(tmp_path, monkeypatch):
def test_resolve_uses_cache_on_second_call(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
monkeypatch.setattr("sys.platform", "linux")
- cached = tmp_path / "simplex-chat" / f"v{LIBS_VERSION}" / "sqlite"
+ cached = tmp_path / "simplex-chat" / LIBS_RELEASE / "sqlite"
cached.mkdir(parents=True)
(cached / "libsimplex.so").touch()
# Should NOT call _download — use the cached file.
@@ -67,9 +69,8 @@ def test_postgres_on_macos_rejected(monkeypatch):
_resolve_libs_dir("postgres")
-def test_atomic_install(tmp_path, monkeypatch):
- """Build a fake libs zip, mock _stream_to_file, verify extraction + atomic rename."""
- # Build zip: libs/libsimplex.so + libs/libHS-stub.so
+def fake_libs_zip(tmp_path, monkeypatch, sha256=None) -> None:
+ """Serve a zip with libs/libsimplex.so + libs/libHS-stub.so, pinned with `sha256` (its own by default)."""
src = tmp_path / "src" / "libs"
src.mkdir(parents=True)
(src / "libsimplex.so").write_text("fake-so")
@@ -84,20 +85,54 @@ def test_atomic_install(tmp_path, monkeypatch):
shutil.copy(zip_path, dest)
+ pinned = sha256 or hashlib.sha256(zip_path.read_bytes()).hexdigest()
monkeypatch.setattr("simplex_chat._native._stream_to_file", fake_stream)
monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64")
+ monkeypatch.setattr(
+ "simplex_chat._native.LIBS_SHA256", {"simplex-chat-libs-linux-x86_64.zip": pinned}
+ )
+
+def test_atomic_install(tmp_path, monkeypatch):
+ fake_libs_zip(tmp_path, monkeypatch)
target = tmp_path / "out"
_download(target, "sqlite")
assert (target / "libsimplex.so").read_text() == "fake-so"
assert (target / "libHS-stub.so").read_text() == "fake-hs"
+def test_download_rejects_another_sha256(tmp_path, monkeypatch):
+ fake_libs_zip(tmp_path, monkeypatch, sha256="0" * 64)
+ target = tmp_path / "out"
+ with pytest.raises(RuntimeError, match=f"expected {'0' * 64}"):
+ _download(target, "sqlite")
+ assert sorted(p.name for p in tmp_path.iterdir()) == ["fake-libs.zip", "src"]
+
+
+def test_download_requires_pinned_sha256(tmp_path, monkeypatch):
+ fake_libs_zip(tmp_path, monkeypatch)
+ with pytest.raises(
+ RuntimeError, match="no SHA-256 is pinned for simplex-chat-libs-linux-x86_64-postgres.zip"
+ ):
+ _download(tmp_path / "out", "postgres")
+
+
def test_libc_on_windows_is_ucrt(monkeypatch):
loaded: list[str | None] = []
monkeypatch.setattr("sys.platform", "win32")
monkeypatch.setattr("ctypes.CDLL", lambda name: loaded.append(name))
- from simplex_chat import _native
-
_native._load_libc()
assert loaded == ["ucrtbase"]
+
+
+def test_lib_for_rejects_backend_switch(monkeypatch):
+ monkeypatch.setattr(_native, "_lib", object())
+ monkeypatch.setattr(_native, "_backend", "sqlite")
+ monkeypatch.setattr(
+ _native, "_resolve_libs_dir", lambda _: pytest.fail("must not resolve libs")
+ )
+ with pytest.raises(RuntimeError) as exc:
+ lib_for("postgres")
+ assert str(exc.value) == (
+ "libsimplex already loaded with backend=sqlite; cannot switch to postgres in the same process"
+ )
diff --git a/packages/simplex-chat-python/tests/test_native_url.py b/packages/simplex-chat-python/tests/test_native_url.py
index 12270c9db1..f1d60d5170 100644
--- a/packages/simplex-chat-python/tests/test_native_url.py
+++ b/packages/simplex-chat-python/tests/test_native_url.py
@@ -3,7 +3,7 @@ from unittest.mock import patch
import pytest
from simplex_chat._native import _libname, _libs_url, _platform_tag
-from simplex_chat._version import LIBS_VERSION
+from simplex_chat._version import LIBS_RELEASE
@patch("sys.platform", "linux")
@@ -45,7 +45,7 @@ def test_url_sqlite(_):
assert (
_libs_url("sqlite")
== "https://github.com/simplex-chat/simplex-chat-libs/releases/download/"
- f"v{LIBS_VERSION}/simplex-chat-libs-linux-x86_64.zip"
+ f"{LIBS_RELEASE}/simplex-chat-libs-linux-x86_64.zip"
)
@@ -54,5 +54,5 @@ def test_url_postgres(_):
assert (
_libs_url("postgres")
== "https://github.com/simplex-chat/simplex-chat-libs/releases/download/"
- f"v{LIBS_VERSION}/simplex-chat-libs-linux-x86_64-postgres.zip"
+ f"{LIBS_RELEASE}/simplex-chat-libs-linux-x86_64-postgres.zip"
)
diff --git a/plans/2026-10-10-desktop-address-no-profile-crash.md b/plans/2026-10-10-desktop-address-no-profile-crash.md
new file mode 100644
index 0000000000..c51eeaff04
--- /dev/null
+++ b/plans/2026-10-10-desktop-address-no-profile-crash.md
@@ -0,0 +1,99 @@
+# Crash in the SimpleX address screen when there is no profile
+
+## Problem
+
+A Windows user who uses the desktop app only through a linked mobile reported this crash:
+
+```
+java.lang.NullPointerException
+ at chat.simplex.common.views.chatlist.ComposableSingletons$UserPickerKt$lambda$-1608374373$1.invoke(UserPicker.kt:223)
+ at chat.simplex.common.views.chatlist.UserPickerKt$UserPicker$6$showCustomModal$1$1$1.invoke(UserPicker.kt:157)
+ at chat.simplex.common.views.helpers.ModalManager$showInView$4$1$1.invoke(ModalView.kt:218)
+```
+
+The "SimpleX address" screen crashes whenever it is shown while there is no active user.
+
+## Solution summary
+
+When there is no current user, the address modal closes itself instead of composing `UserAddressView`. It closes only while it is the top modal, so it does not close a different screen. The new `ModalManager.isLastModal` checks that.
+
+The "Chat preferences" row, which in paths 1 and 2 below left an invisible modal instead of crashing, closes itself the same way.
+
+## Technical design
+
+### Cause
+
+The "SimpleX address" row of the user picker opens `UserAddressView` with `shareViaProfile = it.currentUser.value!!.addressShared`. The modal content reads `currentUser`, so it recomposes whenever `currentUser` changes. It throws whenever it is composed while `currentUser` is null, that is, when there is no active user. A desktop that was only used with a linked mobile has no active user of its own.
+
+The screen can be shown in that state in three ways:
+
+1. **The mobile disconnects.** The screen was opened for the mobile's profile, and then the mobile disconnects. `switchUIRemoteHost(null)` sets `currentUser` to the local user, which is null, and the left-panel modals stay open.
+ - If the address screen is on top, it crashes when it recomposes.
+ - If another screen is on top of it, it crashes when the user goes back to it.
+
+ `UserAddressView` closes itself when the user changes (`KeyChangeEffect` on the user), but with a null user it is never reached.
+2. **The row is clicked with no active user.** With no active profile, the user picker still shows the "Create SimpleX address" row. With no profile and no mobile in use, the picker even opens by itself, at startup or when the state begins while no left-panel screen is open (`App.kt`, `desktopNoUserNoRemote`). Clicking the row crashes immediately.
+
+ At startup, no profile and no connected mobile needs a mobile that was linked before; with no linked mobile, startup shows onboarding instead (`Core.kt`).
+
+ No active user also follows from deleting the active profile when no other visible profile remains, including the only profile, and including the last visible profile of a connected mobile. `doRemoveUser` (`UserProfilesView.kt`) then calls `changeActiveUser_` with no user, which sets `currentUser` from `apiGetActiveUser`. After the deletion there is no active user, so it is null.
+
+3. **The self-destruct passcode is entered while the screen is open.** `deleteStorageAndRestart` (`LocalAuthView.kt`) calls `reinitChatController`. That calls `initChatController`, which sets `currentUser` from the new empty database to null (`Core.kt`). Only later does `deleteStorageAndRestart` create the new profile and call `closeAllModalsEverywhere`.
+ - On Android, the shared modal stack stays composed under the lock screen, so the address screen can recompose with a null user and throw. It throws only if a frame falls after `currentUser` becomes null and before onboarding replaces the main screen, so it does not happen on every run. Android's main loop catches exceptions (`SimplexApp.kt`). `GlobalExceptionsHandler` (`UI.android.kt`) then closes the top modal, recreates the activity and posts a crash alert, and the self-destruct flow hides all alerts at its end, so no crash is visible.
+ - On desktop, `initChatController` replaces the main screen with the splash screen shortly afterwards (`localUserCreated = null`), so the crash needs a frame to fall in that short window. This was not tested.
+
+The "Chat preferences" row has the same cause without the crash. It opens `PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)`, so with no active user the modal content returns early. `PreferencesView` closes itself when the user changes (`KeyChangeEffect` on the user, `Preferences.kt`), but with a null user it is never reached either. The modal stays on the left-panel stack with nothing drawn and no back button, in paths 1 and 2 above.
+
+### Self-closing modal
+
+Both rows use the same effect. `ModalManager.closeModal` closes the top modal that is not already being removed, whichever modal calls it. So the effect closes only when `ModalManager.isLastModal(data)` reports that this modal is that one.
+
+The effect is keyed on the modal and on the modal count of `ModalManager.start` (the left panel on desktop, the shared stack on Android). So it checks again when the count changes, or when its composition is reused for another modal, while this content is still composed. Other than the top modal, a modal stays composed only while it animates out (250 ms, longer if the animation is interrupted). For example, a quick second click on the row reuses the composition of the first modal, which is still animating out.
+
+A modal that is covered after an animation ends is disposed. It is composed afresh when it becomes the top again, for example when the user goes back to it, and the effect runs then.
+
+With the `isLastModal` check, the effect does not close a different screen, such as one opened just before or just after the address screen during an animation. This holds for stack changes on the main thread. As before, `ModalManager` is not synchronized with background calls such as `closeAllModalsEverywhere`.
+
+### Visible behaviour
+
+When there is no active user:
+- Clicking either row now only hides the picker, as opening any modal does. The modal closes itself, and the picker is not reopened.
+- After the mobile disconnects with the address or preferences screen open, the picker does not reopen by itself (observed in testing). When the state with no user and no mobile in use begins, the app checks for open left-panel modals, and the modal closes itself only after that check. Master does the same when any other left-panel screen is open.
+
+The change is in common code. On Android the user picker is not reachable without an active user, because deleting the last visible profile returns to onboarding. In the self-destruct case on Android, the screen can no longer throw, so `GlobalExceptionsHandler` does not have to recover. With an active user, behaviour does not change on any platform.
+
+### Security
+
+The change is UI-only and crosses no trust boundary: it reads local state and closes a local screen. It sends nothing and changes no stored data.
+
+### Rejected alternative
+
+An early `return@showCustomModal`, as the "Chat preferences" row did, would avoid the crash. But it would leave an invisible modal on the left-panel stack, with no back button. It stays until something closes the left-panel modals, such as a click on the centre panel. If a profile is created first, it comes back as the address screen. The "Chat preferences" row has this behaviour on master; this change gives it the self-closing effect too.
+
+## Implementation steps
+
+1. `views/helpers/ModalView.kt`: add `ModalManager.isLastModal(data)`, true when `data` belongs to the top modal that is not being removed.
+2. `views/chatlist/UserPicker.kt`: in the "SimpleX address" row, compose `UserAddressView` only when `currentUser` is not null. Otherwise run `LaunchedEffect(modal, ModalManager.start.modalCount.value)`, which closes the modal when `isLastModal(modal)` is true. Do the same in the "Chat preferences" row for `PreferencesView`.
+3. Documentation, under `apps/multiplatform/`:
+ - `spec/client/navigation.md`: list `isLastModal` among the `ModalManager` methods.
+ - `spec/client/chat-list.md`, UserPicker section: describe the address and preferences rows closing themselves with no active user.
+ - `product/views/user-profiles.md`: describe both rows with no active profile.
+
+## Verification
+
+Desktop AppImages from master and from this branch, each with a fresh database, unless stated otherwise. The v7.1.0-beta.6 CLI (core v7.1.0.10) acts as the linked mobile (`/connect remote ctrl`). "Leftover" is checked by creating a profile afterwards: a leftover modal reappears as the address screen when onboarding finishes.
+
+- master (at the merge base), case 1: NPE at `UserPicker.kt:223`.
+- master (at the merge base), case 2: NPE at `UserPicker.kt:223` with the reported stack (`showCustomModal` 157, `ModalView.kt:218`).
+- This branch, case 1 with the address screen on top: no crash, and the screen closes.
+- This branch, case 1 with the "Address or 1-time link?" screen (opened from "SimpleX address or 1-time link?") on top: no crash. That screen stays open after the disconnect, as on master. Going back closes the address screen and shows the chat list.
+- This branch, case 2, double click (seven runs, 8 to 40 ms apart): no crash, and no leftover. With `LaunchedEffect(Unit)` the address screen reappeared after onboarding in 2 of 3 runs at 30 ms.
+- This branch, case 2, "Settings" then "Create SimpleX address" 100 ms apart: no crash, and Settings stays open. With the close keyed on the modal count but without the `isLastModal` check, Settings was closed too.
+- This branch, with a local profile: the address screen opens and closes normally. When the mobile disconnects with its address screen open, the screen closes through `UserAddressView`'s own user-change effect, and the app returns to the local profile.
+- "Chat preferences", desktop with no profile, the same CLI linked as the mobile:
+ - Preferences open, then the mobile disconnects. Before this change the screen disappeared, and after a profile was created it came back as "Your preferences" over the chat. With this change it does not come back, and there is no crash.
+ - Row clicked with no profile and no mobile: nothing opens, and nothing comes back after a profile is created.
+ - With a profile: the screen opens and closes normally.
+- Android (Waydroid: Android 13 with arm64 translation), debug APKs of master and of this branch, fresh app data, case 3 with the address screen opened from the user picker:
+ - master, two runs: in one, logcat shows `App crashed, thread name: main` with the NPE at `UserPicker.kt:223`; in the other, no exception. No crash is visible in either, as in an earlier manual test.
+ - This branch: one run completed to the new profile with no exception in logcat. As master did not throw on every run, this checks for no new failure rather than proving the fix. An earlier run hung during self-destruct, with the main thread, a coroutine thread and a Haskell worker all waiting for the native allocator lock under arm64 translation.
diff --git a/plans/2026-10-11-markdown-file-viewer.md b/plans/2026-10-11-markdown-file-viewer.md
new file mode 100644
index 0000000000..c5152ea485
--- /dev/null
+++ b/plans/2026-10-11-markdown-file-viewer.md
@@ -0,0 +1,319 @@
+# Markdown file viewer: implementation plan
+
+Markdown files from chats are viewed in the Android, desktop and iOS apps, formatted or as text.
+
+## 1. Files that qualify
+
+By name and size only:
+
+```
+name file.fileName ends with .md or .markdown, case-insensitive
+size file.fileSize <= MAX_MARKDOWN_VIEW_SIZE
+```
+
+```
+MAX_MARKDOWN_VIEW_SIZE 1_048_576
+Kotlin views/chat/item/MarkdownFileView.kt, private
+iOS Shared/Views/Chat/ChatItem/MarkdownFileView.swift, private
+```
+
+Function, in each viewer file:
+
+```
+Kotlin fun isMarkdownFile(file: CIFile): Boolean
+iOS func isMarkdownFile(_ file: CIFile) -> Bool
+```
+
+Other files, and Markdown files over the limit: tap actions unchanged.
+
+## 2. Tap and long press
+
+Tap on a qualifying file, every platform:
+
+```
+file status action
+received or sent, file loaded viewer
+desktop with a linked mobile loadRemoteFile, then viewer
+RcvInvitation, RcvAborted reception started; viewer shown on completion, in the same place
+other statuses as now: waiting alerts, error alerts
+```
+
+Long press: the existing Save item of the item menu (`ChatItemView.kt`, `ChatView.swift`).
+
+Places:
+
+```
+Kotlin CIFileView.kt, onClick and fileAction
+iOS CIFileView.swift, fileAction
+```
+
+- Android: "Open with" of `SaveOrOpenFileMenu` is not shown for qualifying files; "Share" in the item menu stays.
+- Present locally: `getLoadedFilePath(file)` (Kotlin), `getLoadedFileSource(file)` (iOS), as for other files.
+
+## 3. Open on completion
+
+In master (#7700), for images, voice messages, video and files. The viewer adds only the Markdown case to the file tap action.
+
+A tap that starts reception registers the file. On completion the tap action of the received file is performed:
+
+```
+image full screen view
+voice playback
+video playback; locally encrypted: decrypted first; chat list preview: full screen player
+file Markdown: viewer
+ other: share sheet (iOS), open/save menu (Android), save dialog (desktop)
+```
+
+Kotlin, chat model:
+
+```
+fileToOpen file ID of the last tapped file
+ inChatList: tap in a chat list preview
+openWhenReceived(file, inChatList) sets fileToOpen
+receivedFileToOpen(file, inChatList) false unless fileToOpen has file.fileId and inChatList, and the file is loaded
+ then fileToOpen cleared
+ chat view: true
+ chat list preview: true on desktop, or when no chat is open
+```
+
+Item views: `OpenWhenReceivedEffect(file, inChatList, open)` (`CIFileView.kt`), on a change of `file.loaded`; in `CIImageView.kt`, `CIVoiceView.kt`, `CIVideoView.kt`, `CIFileView.kt`.
+
+iOS, chat model:
+
+```
+fileToOpen file ID of the last tapped file
+ chat ID at the tap; nil in the chat list
+ open callback of the tapped view
+openWhenReceived(file, open) sets fileToOpen
+receivedFileToOpen(file) fileToOpen has file.fileId, and the open chat is the chat at the tap
+openReceivedFile(file) receivedFileToOpen rechecked; fileToOpen cleared; open(file)
+```
+
+Event `rcvFileComplete` (`SimpleXAPI.swift`): for the active user, when `receivedFileToOpen(file)`: `openReceivedFile(file)` on the main actor.
+
+`fileToOpen` is replaced by a later tap.
+
+## 4. Reading in memory
+
+The file is read and decoded off the main thread, before the viewer is shown.
+
+```
+Kotlin withLongRunningApi
+ encrypted: readCryptoFile(path, cryptoArgs) (CryptoFile.kt:36)
+ plain: File(path).readBytes()
+ text: UTF-8 CharsetDecoder, REPLACE with "?"
+ parsed: parseMarkdownFlow (renderer), once, before the viewer is shown
+iOS Task
+ getFileData(path, cryptoArgs) (SimpleXChat/ImageUtils.swift:41)
+ text: Unicode.UTF8.ForwardParser, "?" for each invalid byte
+ html: MarkdownParser with an .html modifier, raw HTML escaped
+```
+
+Text decoding, as the core safeDecodeUtf8 (simplexmq Util.hs:383-386):
+
+```
+invalid UTF-8 "?" for each invalid byte
+C0 controls U+0000-U+001F replaced with "?", except tab, line feed, carriage return
+line endings CRLF and CR are line breaks: Kotlin text.lines(); iOS: replaced with LF before parsing
+```
+
+Limits, checked before rendering; over a limit: text view only, the switch hidden:
+
+```
+line length 15 000 characters
+nesting Kotlin: 32 levels of the parsed tree; iOS: 16 levels estimated from line prefixes
+link nesting iOS: 4 open square brackets, counted until an empty line
+table rows 200
+parse error Kotlin: text view only
+```
+
+Text view: lines longer than 15 000 characters are split.
+
+- `readCryptoFile`: core `chatReadFile` (`Mobile/File.hs:87`); the file is decrypted by `CF.readFile` into memory.
+- Bytes, text and HTML are held only in memory, in the viewer's state.
+- Read error: alert with the error text; the viewer is not shown.
+- File absent: "File not found" alert (Kotlin); no action (iOS), as for other files.
+
+## 5. Viewer
+
+```
+presentation Android: full screen, like the image viewer
+ desktop: over the conversation
+ iOS: sheet
+toolbar back (Kotlin)
+ switch between the formatted and the text view
+ save
+title file name
+preference view chosen last; default formatted
+```
+
+### 5.1. Kotlin
+
+Renderer: `com.mikepenz:multiplatform-markdown-renderer` and `-m2`, 0.27.0 → 0.34.0 (`common/build.gradle.kts:80-81`).
+
+```
+0.34.0 dependencies kotlin-stdlib 2.1.20, org.jetbrains:markdown 0.7.3, kotlinx-collections-immutable 0.3.8
+compose compileOnly; built with Compose 1.8.0, the app uses 1.8.2
+app Kotlin 2.1.20, Compose Multiplatform 1.8.2
+added table component, LazyMarkdownSuccess, success lambda of Markdown()
+```
+
+New file `views/chat/item/MarkdownFileView.kt`:
+
+```
+isMarkdownFile(file)
+showMarkdownFile(file) loadRemoteFile if needed, read, ModalManager.center.showModal
+MarkdownFileView formatted or text view by appPrefs.markdownFileTextView
+```
+
+Formatted view:
+
+```
+renderer m2 Markdown(state), GFM flavour, the parsed state from showMarkdownFile
+styles documentMarkdownStyle(h1) (OperatorView.kt), shared with the operator conditions
+ h1: typography h2, 26 sp, bold
+lazy success lambda: LazyColumnWithScrollBar, title item, one item per top-level node: MarkdownElement in a Column
+links LocalUriHandler: markdownFileUriHandler
+code spans annotator: source text between the backticks; inlineCode style, inlineCodeBackground
+images data:image URIs: DataImageTransformer, decoded off the main thread by decodeBase64Image
+ other image links: the annotator appends the source text; nothing is fetched
+tables default table component
+```
+
+Links, classified by the core markdown parser (`parseToMarkdown`), as message links:
+
+```
+SimplexLink openVerifiedSimplexUri
+Uri, http or https openBrowserAlert
+other showInvalidLinkAlert
+```
+
+Displayed link targets (`displayedUri`, Kotlin and iOS; also in message link alerts and the external-link alert):
+
+```
+format characters (Cf) [U+XXXX]
+non-ASCII in the authority [U+XXXX]
+HTML not parsed (parseHtml = false)
+```
+
+Images:
+
+```
+decode decodeBase64Image(base64, window width): power-of-two subsampling, both sides kept at or above the window width
+ Android: decodeSampledBitmapFromByteArray, 4320 px per side
+ desktop: decodeBoundedBufferedImage, 16384 px per side, 4320 × 4320 decoded pixels
+budget 2160 × 4320 decoded pixels per document; images over the budget are not shown
+cache per document, in the transformer; the desktop base64BitmapCache is not used
+```
+
+Text view: `LazyColumnWithScrollBar`, title item, one monospace `Text` per line, inside `SelectionContainer`; format characters shown as `[U+XXXX]` markers.
+
+Bidi controls (U+061C, U+200E, U+200F, U+202A-U+202E, U+2066-U+2069): warning under the title, both views.
+
+Toolbar end buttons:
+
+```
+switch ic_code in the formatted view, ic_article in the text view; sets appPrefs.markdownFileTextView; hidden over the limits
+save ic_download; rememberFileChooserLauncher; copyBytesToFile(ByteArrayInputStream(bytes)) (Files.kt:65)
+```
+
+`OperatorView.kt`:
+
+```
+markdownColor(linkText = primary) markdownColor(); link colour moved to the link text style (linkText deprecated in 0.34.0)
+ConditionsMarkdown styles moved to documentMarkdownStyle(h1): DocumentMarkdownStyle (colors, typography, components, padding)
+ h1: body1 by default, as in the conditions
+```
+
+`SimpleXAPI.kt`: `markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, false)`.
+
+Centre placement on desktop: modals are drawn over the open conversation.
+
+```
+ModalView.kt showCustomModal: chatId is not changed
+App.kt CenterPartOfScreen: ModalManager.center.showInView() after ChatView
+ChatListNavLinkView.kt "Set contact name": back button on desktop
+```
+
+`moko-resources/base/strings.xml`:
+
+```
+markdown_formatted_view Formatted view
+markdown_text_view Text view
+markdown_file_bidi_warning This file has text direction characters. The text can be shown in a different order than it is written.
+```
+
+### 5.2. iOS
+
+New file `Shared/Views/Chat/ChatItem/MarkdownFileView.swift`, added to the SimpleX target in `project.pbxproj`.
+
+```
+isMarkdownFile(file)
+showMarkdownFile(file) read, parse, showAppSheet
+MarkdownFileView NavigationView; toolbar: switch, save
+```
+
+```
+formatted ConditionsWebView(html:openLink:), Ink 0.6.0, raw HTML escaped by an .html modifier
+text ScrollView, LazyVStack, one monospace Text per line, text selection, format characters as [U+XXXX]
+switch @AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW); hidden over the limits
+save saveCryptoFile(fileSource) (CIFileView.swift)
+close swipe down
+links openMarkdownFileLink: parseSimpleXMarkdown; simplexLink: appOpenUrl; uri, http or https: openBrowserAlert; other: showInvalidLinkAlert
+```
+
+`SettingsView.swift`: `DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView"`, default false.
+
+`ConditionsWebView.swift`, shared with the operator conditions:
+
+```
+configuration allowsContentJavaScript = false; websiteDataStore = .nonPersistent()
+head Content-Security-Policy: default-src 'none'; style-src 'nonce-'; img-src data:; base-uri 'none'; form-action 'none'
+style the app's style block with the nonce; table, th, td borders; pre overflow-x auto; img max-width 100%
+navigation linkActivated: openLink (openExternalLink by default)
+ other navigation: allowed for about: URLs only
+```
+
+- Ink 0.6.0 applies `.html` modifiers to block and inline HTML fragments (`HTMLConvertible.swift`); the viewer's modifier escapes `&`, `<`, `>`; code spans and blocks are escaped by Ink itself.
+
+Images, an `.images` modifier (`imagesWithinBudget`):
+
+```
+data:image, base64 shown as
, rebuilt from the source only
+ 4320 px per side; 2160 × 4320 pixels per document; size read by UIImage(data:)
+over a limit not shown
+other data URIs not shown
+other image links the source text; nothing is fetched
+```
+
+## 6. Size limit
+
+1 MB.
+4 MB: formatted views render lazily (Kotlin: one lazy item per top-level node; iOS: WKWebView); text views render one lazy item per line.
+A single top-level block (one table, code block or paragraph) is still laid out at once.
+
+## 7. Checks
+
+1. Plain and encrypted files: formatted and text views on Android, desktop and iOS.
+2. Desktop linked to a mobile: file loaded from the mobile, then shown.
+3. File over 1 MB: tap action as before.
+4. File not received: one tap; viewer shown on completion in the same chat; not shown after leaving the chat.
+5. Same for images, voice messages, video and other files, each with its tap action.
+6. Tap in a chat list preview: opened on completion while the chat list is shown; Android, iOS: not opened after a chat is opened; desktop: opened while a chat is open.
+7. Not opened after a reception error, cancel or abort. Kotlin: not opened after the item scrolled out of view.
+8. Invalid UTF-8 and control characters: shown as "?".
+9. Lines over 15 000 characters, 33 tree levels (Kotlin), 17 nesting levels (iOS), 5 nested square brackets (iOS), 201 table rows: text view only, long lines split.
+10. Bidi controls: warning; format characters as [U+XXXX] in the text view.
+11. Data images: decoded to the window width (Kotlin); images over 4320 px per side (Android, iOS) or beyond 2160 × 4320 pixels per document not shown.
+12. Links: SimpleX links to the connect flow; http(s) links to the "Open web link?" alert; other schemes and relative links: "Invalid link"; targets with invisible or non-ASCII host characters shown with [U+XXXX].
+13. View switch kept after closing the viewer and after a restart.
+14. Save: the file at the chosen destination equals the original.
+15. Link inside backticks: shown as code.
+16. Remote image: not loaded, the source text shown; data URI image: shown.
+17. iOS inline HTML with `
+{% if not noEvents %}
+
+{% endif %}
+
diff --git a/website/src/fdroid.html b/website/src/fdroid.html
index ff56b9cc95..c3441d9680 100644
--- a/website/src/fdroid.html
+++ b/website/src/fdroid.html
@@ -38,6 +38,11 @@ templateEngineOverride: njk
9F 35 8F F2 84 D1 F7 16 56 A2 BF AF 0E 00 5D EA E6 AA 14 14 37 20 E0 89 F1 1F F2 DD CF EB 01 BA
+
+ {{ "f-droid-page-apk-text" | i18n({}, lang ) | safe }}
+ aarch64,
+ armv7
+
@@ -48,7 +53,7 @@ templateEngineOverride: njk
{{ "f-droid-org-repo" | i18n({}, lang ) | safe }}
-

+
{{ "stable-versions-built-by-f-droid-org" | i18n({}, lang ) | safe }}
{{ "releases-to-this-repo-are-done-1-2-days-later" | i18n({}, lang ) | safe }}.
{{ "f-droid-page-f-droid-org-repo-section-text" | i18n({}, lang ) | safe }}
diff --git a/website/src/file.html b/website/src/file.html
index c6174ce33b..e5a25b446b 100644
--- a/website/src/file.html
+++ b/website/src/file.html
@@ -4,6 +4,7 @@ title: "SimpleX File Transfer"
description: "Send files securely with end-to-end encryption"
templateEngineOverride: njk
active_file: true
+noEvents: true
---
{% set lang = page.url | getlang %}
{% from "components/macro.njk" import overlay %}
@@ -229,11 +230,11 @@ active_file: true
{{ "file-cta-subheading" | i18n({}, lang) | safe }}
diff --git a/website/src/img/downloads/android-dark.webp b/website/src/img/downloads/android-dark.webp
new file mode 100644
index 0000000000..085bf28992
Binary files /dev/null and b/website/src/img/downloads/android-dark.webp differ
diff --git a/website/src/img/downloads/android-light.webp b/website/src/img/downloads/android-light.webp
new file mode 100644
index 0000000000..96afe5e362
Binary files /dev/null and b/website/src/img/downloads/android-light.webp differ
diff --git a/website/src/img/downloads/desktop-dark.webp b/website/src/img/downloads/desktop-dark.webp
new file mode 100644
index 0000000000..ba81d2d699
Binary files /dev/null and b/website/src/img/downloads/desktop-dark.webp differ
diff --git a/website/src/img/downloads/desktop-light.webp b/website/src/img/downloads/desktop-light.webp
new file mode 100644
index 0000000000..2de2ea64ac
Binary files /dev/null and b/website/src/img/downloads/desktop-light.webp differ
diff --git a/website/src/img/new/f_droid_icon.svg b/website/src/img/new/f_droid_icon.svg
new file mode 100644
index 0000000000..f71032cfbf
--- /dev/null
+++ b/website/src/img/new/f_droid_icon.svg
@@ -0,0 +1,50 @@
+
+
diff --git a/website/src/img/why/why-video.jpg b/website/src/img/why/why-video.jpg
new file mode 100644
index 0000000000..4b81483661
Binary files /dev/null and b/website/src/img/why/why-video.jpg differ
diff --git a/website/src/index.html b/website/src/index.html
index 57580c1f4d..31e6e086f8 100644
--- a/website/src/index.html
+++ b/website/src/index.html
@@ -42,11 +42,6 @@ active_home: true
-
-
-
-
-
@@ -57,12 +52,11 @@ active_home: true
-
-
-
-
-
-
+
+
+
+
+
@@ -86,26 +80,14 @@ active_home: true
.sd-icon { display: inline-block; width: 0.85em; height: 0.85em; vertical-align: -0.08em; margin-left: 0.35em; }
.sd-icon path, .sd-icon polygon { fill: #0095e7; }
.dark .sd-icon path, .dark .sd-icon polygon { fill: #c8feff; }
- @media screen and (max-width: 959px) {
- .sd-sale-banner { top: 0; left: 0; right: 0; transform: none; z-index: 50001; pointer-events: none; }
- .sd-lead, .sd-tail {
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- white-space: nowrap;
- pointer-events: auto;
- font-size: calc(var(--sec-vwu) * 4.675);
- font-weight: 500;
- background-image: linear-gradient(90deg, #001aa7 0%, #0095e7 100%);
- -webkit-background-clip: text;
- background-clip: text;
- -webkit-text-fill-color: transparent;
- }
- .dark .sd-lead, .dark .sd-tail {
- background-image: linear-gradient(90deg, #019bfe 0%, #64fdff 58%, #c8feff 100%);
- }
- .sd-lead { top: 0; line-height: 54px; }
- .sd-tail { top: min(56px, calc(27px + var(--sec-vwu) * 29 / 6.2)); }
+ @media screen and (min-width: 1016px), screen and (max-width: 1015px) and (orientation: landscape) {
+ .sd-sale-banner { top: auto; bottom: calc(22px + var(--cover-bottom-ext, 0px)); transform: translateX(calc(-50% - max(0px, 50% + var(--cover-socials-w, 435px) + 54px - 50vw))); font-size: 16px; text-align: center; background-image: none; -webkit-text-fill-color: #ffffff; color: #ffffff; }
+ [dir="rtl"] .sd-sale-banner { transform: translateX(calc(-50% + max(0px, 50% + var(--cover-socials-w, 435px) + 54px - 50vw))); }
+ .sd-lead, .sd-tail { display: block; }
+ .sd-icon path, .sd-icon polygon { fill: #ffffff; }
+ }
+ @media screen and (max-width: 1015px) and (orientation: portrait), screen and (max-width: 1015px) and (orientation: landscape) and (max-height: 500px) {
+ .sd-sale-banner { display: none; }
}
@@ -137,12 +119,13 @@ active_home: true
- SimpleX domains launch
+ {{ "index-hero-countdown-lead" | i18n({}, lang) }}
+
+

+
+
-
{{ "index-hero-h1" | i18n({}, lang) | safe }}
+
{{ "index-hero-h1" | i18n({}, lang) | replace("
", "
") | safe }}
{{ "index-hero-h2" | i18n({}, lang) | safe }}
{{ "index-hero-p1" | i18n({}, lang) | safe }}
-
{{ "index-hero-invest" | i18n({}, lang) | safe }} {{ "learn-more" | i18n({}, lang) }}.
+
{{ "index-hero-own-part" | i18n({}, lang) | safe }} {{ "index-hero-invest" | i18n({}, lang) | safe }}
+
+
3M{{ "index-hero-downloads" | i18n({}, lang) }}
+
600K{{ "index-hero-monthly-users" | i18n({}, lang) }}
+
@@ -251,7 +224,7 @@ active_home: true
{{ "index-token-h2" | i18n({}, lang) }}
{{ "index-token-p1" | i18n({}, lang) }}
{{ "index-token-p2-cf" | i18n({}, lang) | safe }}
-
{{ "index-token-cta-cf" | i18n({}, lang) | safe }}
+
{{ "index-token-cta-cf" | i18n({}, lang) | safe }}
@@ -288,36 +261,36 @@ active_home: true
-

-

-

-

-

-

-

-

-

-

-

-

-
+ 
+

+

+

+

+

+

+

+

+

+

+

+
-

-

-

-

-

-

-

-

-

-

-

-

-

-

+

+

+

+

+

+

+

+

+

+

+

+

+

+
{{ "index-directory-h2" | i18n({}, lang) }}
@@ -353,6 +326,20 @@ active_home: true
})();
+
+
+
Invest in SimpleX Chat
@@ -18,7 +30,7 @@ eleventyExcludeFromCollections: true
-
+