From 9a90d8e2302d7f9eaa89ba08c2cc9cd16cc48a7c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 17 Oct 2026 17:12:08 +0100 Subject: [PATCH 01/10] ci: cache build artifacts for faster builds (#7730) * ci: cache build artifacts for faster builds * test build * compression * fix macos-latest * debug build * better caching * test cache * update build * test cache * ci: check toolchain restore * ci: remove Windows toolchain cache * ci: skip tests on macOS Intel --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .github/actions/prepare-build/action.yml | 20 +- .github/workflows/build.yml | 573 ++++++++++++++++-- plans/2026-10-15-ci-build-cache.md | 243 ++++++++ scripts/ci/prune-old-builds.sh | 9 + scripts/ci/source-timestamps.sh | 27 + scripts/desktop/build-lib-linux.sh | 7 +- .../SQLite/Migrations/agent_query_plans.txt | 4 - tests/ChatTests/Groups.hs | 2 + 8 files changed, 818 insertions(+), 67 deletions(-) create mode 100644 plans/2026-10-15-ci-build-cache.md create mode 100755 scripts/ci/prune-old-builds.sh create mode 100755 scripts/ci/source-timestamps.sh diff --git a/.github/actions/prepare-build/action.yml b/.github/actions/prepare-build/action.yml index d64d579520..13871a25b8 100644 --- a/.github/actions/prepare-build/action.yml +++ b/.github/actions/prepare-build/action.yml @@ -10,21 +10,19 @@ inputs: github_ref: required: true description: "Git reference" - os: - required: true - description: "Target OS" - cache_path: - required: false - default: "~/.cabal/store" - description: "Cache path" cabal_ver: required: false default: 3.10.2.0 description: "GHC version to install" +outputs: + cabal-store: + description: "Cabal store path" + value: ${{ steps.setup.outputs.cabal-store }} runs: using: "composite" steps: - name: Setup Haskell + id: setup uses: simplex-chat/setup-haskell-action@v2 with: ghc-version: ${{ inputs.ghc_ver }} @@ -37,11 +35,3 @@ runs: distribution: 'corretto' java-version: ${{ inputs.java_ver }} cache: 'gradle' - - - name: Restore cached build - uses: actions/cache@v4 - with: - path: | - ${{ inputs.cache_path }} - dist-newstyle - key: ${{ inputs.os }}-ghc${{ inputs.ghc_ver }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a3400f3ffa..37395dbaf3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,14 @@ on: - "scripts/desktop/**" - ".github/**" +env: + opt: ${{ github.event_name == 'pull_request' && 'O0' || 'O2' }} + ghc_options: ${{ github.event_name == 'pull_request' && '--ghc-options=-O0' || '' }} + release: ${{ startsWith(github.ref, 'refs/tags/v') }} + branch: ${{ github.base_ref || github.ref_name }} + cache_o0: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') }} + ZSTD_CLEVEL: 9 + # This workflow uses custom actions (prepare-build and prepare-release) defined in: # # .github/actions/ @@ -162,6 +170,12 @@ jobs: name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] runs-on: ${{ matrix.runner }} + permissions: + contents: write + packages: write + env: + image_cache: ghcr.io/simplex-chat/simplex-chat-build-cache:${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }} + source_hashes: dist-newstyle/sources.sha256 concurrency: group: ${{ github.workflow }}-${{ github.ref }}-linux-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.ghc }}-${{ needs.maybe-release.outputs.libs_tag }} cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} @@ -231,19 +245,22 @@ jobs: shell: bash run: ./scripts/ci/linux_util_free_space.sh - - name: Restore cached build - if: matrix.should_run == true - uses: actions/cache@v4 - with: - path: | - ~/.cabal/store - dist-newstyle - key: ubuntu-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} - - name: Set up Docker Buildx if: matrix.should_run == true uses: simplex-chat/docker-setup-buildx-action@v3 + - name: Log in to GitHub Container Registry + if: matrix.should_run == true && env.release != 'true' + shell: bash + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: echo "$GHCR_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: Compute image cache week + id: week + if: matrix.should_run == true && env.release != 'true' + run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT" + - name: Build and cache Docker image if: matrix.should_run == true uses: simplex-chat/docker-build-push-action@v6 @@ -258,6 +275,8 @@ jobs: GHC=${{ matrix.ghc }} USER_UID=${{ steps.ids.outputs.uid }} USER_GID=${{ steps.ids.outputs.gid }} + cache-from: ${{ env.release != 'true' && format('type=registry,ref={0}-{1}', env.image_cache, steps.week.outputs.week) || '' }} + cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && format('type=registry,ref={0}-{1},mode=max,ignore-error=true', env.image_cache, steps.week.outputs.week) || '' }} # Docker needs these flags for AppImage build: # --device /dev/fuse @@ -267,16 +286,94 @@ jobs: if: matrix.should_run == true shell: bash run: | + mkdir -p ~/.cabal docker run -t -d \ --device /dev/fuse \ --cap-add SYS_ADMIN \ --security-opt apparmor:unconfined \ --name builder \ - -v ~/.cabal:/root/.cabal \ + -e CABAL_DIR=/home/builder/.cabal \ + -v ~/.cabal:/home/builder/.cabal \ -v /home/runner/work/_temp:/home/runner/work/_temp \ -v ${{ github.workspace }}:/project \ build/${{ matrix.os }}:latest + - name: Compute cache key prefixes + id: keys + if: matrix.should_run == true && env.release != 'true' + run: | + toolchain=$(docker exec builder dpkg-query -W -f '${db:Status-Status} ${binary:Package} ${Version}\n' 'binutils*' 'cpp*' 'gcc*' '*-dev' | grep '^installed ' | sha256sum | cut -c1-16) + target=ubuntu-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-$toolchain + echo "deps=deps-$target" >> "$GITHUB_OUTPUT" + echo "dist=dist-$target" >> "$GITHUB_OUTPUT" + echo "dist_lib=dist-lib-$target" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies from cache + id: restore-deps + if: matrix.should_run == true && env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: ~/.cabal/store + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.deps }}-${{ env.branch }}- + ${{ steps.keys.outputs.deps }}- + + - name: Warn if dependencies not in cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-deps.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" + + - name: Restore source repositories from cache + id: restore-src + if: matrix.should_run == true && env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + restore-keys: src-${{ runner.os }}- + + - name: Warn if source repositories not in cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-src.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::src-${{ runner.os }}" + + - name: Restore build directory from cache + id: restore-build + if: matrix.should_run == true && env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-${{ env.opt }}- + + - name: Warn if build directory not in cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-build.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}" + + - name: Restore library build directory from cache + id: restore-build-lib + if: env.release != 'true' && matrix.libs && needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64' + uses: actions/cache/restore@v4 + with: + path: | + dist-lib/* + !dist-lib/src + key: ${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}- + ${{ steps.keys.outputs.dist_lib }}- + + - name: Warn if library build directory not in cache + if: steps.restore-build-lib.outcome == 'success' && steps.restore-build-lib.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}" + + - name: Set old timestamps on unchanged sources (GHC 8.10.7) + if: matrix.ghc == '8.10.7' && steps.restore-build.outputs.cache-matched-key != '' && hashFiles(env.source_hashes) != '' + run: scripts/ci/source-timestamps.sh restore ${{ env.source_hashes }} + - name: Prepare cabal.project.local if: matrix.should_run == true shell: bash @@ -285,33 +382,81 @@ jobs: echo "package direct-sqlcipher" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local - # chmod/git commands are used to workaround permission issues when cache is restored - name: Build CLI + id: build-cli if: matrix.should_run == true shell: docker exec -t builder sh -eu {0} run: | - cabal clean + scripts/ci/prune-old-builds.sh dist-newstyle cabal update - cabal build -j --enable-tests + cabal build -j --enable-tests ${{ env.ghc_options }} mkdir -p /out for i in simplex-chat simplex-chat-test; do bin=$(find /project/dist-newstyle -name "$i" -type f -executable) chmod +x "$bin" - mv "$bin" /out/ + cp "$bin" /out/ done strip /out/simplex-chat # Also for pull requests that change the chat core, for the package tests (packages-pr). - name: Build library + id: build-lib 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 + scripts/ci/prune-old-builds.sh dist-lib + mkdir -p dist-lib + rm -rf dist-lib/src + ln -s ../dist-newstyle/src dist-lib/src + DIST_DIR=dist-lib SKIP_VLC=1 scripts/desktop/build-lib-linux.sh name=simplex-chat-libs-linux-${{ matrix.arch }} mkdir -p release-libs/$name/libs 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: Record source hashes (GHC 8.10.7) + if: matrix.should_run == true && matrix.ghc == '8.10.7' + shell: docker exec -t builder sh -eu {0} + run: scripts/ci/source-timestamps.sh record ${{ env.source_hashes }} + + - name: Compute dependency cache key + id: hash-deps + if: matrix.should_run == true && env.release != 'true' + shell: bash + run: echo "hash=$(ls ~/.cabal/store/ghc-*/ | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Save dependencies to cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + uses: actions/cache/save@v4 + with: + path: ~/.cabal/store + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} + + - name: Save source repositories to cache + if: matrix.should_run == true && env.release != 'true' && matrix.libs && matrix.arch == 'x86_64' && steps.restore-src.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + + - name: Save build directory to cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-build.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + + - name: Save library build directory to cache + if: steps.build-lib.outcome == 'success' && env.release != 'true' && steps.restore-build-lib.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + dist-lib/* + !dist-lib/src + key: ${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}-${{ github.sha }} + - name: Upload library artifact if: matrix.libs && needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64' uses: actions/upload-artifact@v7 @@ -437,13 +582,6 @@ jobs: github_ref: ${{ github.ref }} github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Fix permissions for cache - if: matrix.should_run == true - shell: bash - run: | - sudo chmod -R 777 dist-newstyle ~/.cabal - sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal - - name: Mount RAM disk for tests if: matrix.should_run == true && matrix.arch == 'x86_64' shell: bash @@ -474,6 +612,51 @@ jobs: exit 1 fi + - name: Remove -O2 build directory + id: remove-o2 + if: env.cache_o0 == 'true' && !cancelled() && steps.build-cli.outcome == 'success' + shell: docker exec -t builder sh -eu {0} + run: find dist-newstyle -mindepth 1 -maxdepth 1 ! -name src -exec rm -rf {} + + + - name: Restore -O0 build directory from cache + id: restore-build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-O0- + + - name: Set old timestamps on unchanged sources for -O0 build (GHC 8.10.7) + if: ${{ !cancelled() && matrix.ghc == '8.10.7' && steps.restore-build-o0.outputs.cache-matched-key != '' && hashFiles(env.source_hashes) != '' }} + run: scripts/ci/source-timestamps.sh restore ${{ env.source_hashes }} + + - name: Build at -O0 for pull request caches + id: build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + shell: docker exec -t builder sh -eu {0} + run: | + scripts/ci/prune-old-builds.sh dist-newstyle + cabal build -j --enable-tests --ghc-options=-O0 + + - name: Record source hashes for -O0 build (GHC 8.10.7) + if: ${{ !cancelled() && matrix.ghc == '8.10.7' && steps.build-o0.outcome == 'success' }} + shell: docker exec -t builder sh -eu {0} + run: scripts/ci/source-timestamps.sh record ${{ env.source_hashes }} + + - name: Save -O0 build directory to cache + if: ${{ !cancelled() && steps.build-o0.outcome == 'success' && steps.restore-build-o0.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + # ================================= # Linux PostgreSQL Library Build # ================================= @@ -497,14 +680,6 @@ jobs: shell: bash run: ./scripts/ci/linux_util_free_space.sh - - name: Restore cached build - uses: actions/cache@v4 - with: - path: | - ~/.cabal/store - dist-newstyle - key: ubuntu-22.04-x86_64-postgres-ghc${{ needs.variables.outputs.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} - - name: Set up Docker Buildx uses: simplex-chat/docker-setup-buildx-action@v3 @@ -525,13 +700,35 @@ jobs: - name: Start container shell: bash run: | + mkdir -p ~/.cabal docker run -t -d \ --name builder \ - -v ~/.cabal:/root/.cabal \ + -e CABAL_DIR=/home/builder/.cabal \ + -v ~/.cabal:/home/builder/.cabal \ -v /home/runner/work/_temp:/home/runner/work/_temp \ -v ${{ github.workspace }}:/project \ build/22.04:latest + - name: Compute cache key prefix + id: keys + run: | + toolchain=$(docker exec builder dpkg-query -W -f '${db:Status-Status} ${binary:Package} ${Version}\n' 'binutils*' 'cpp*' 'gcc*' '*-dev' | grep '^installed ' | sha256sum | cut -c1-16) + echo "deps=deps-ubuntu-22.04-x86_64-postgres-ghc${{ needs.variables.outputs.GHC_VER }}-$toolchain" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies from cache + id: restore-deps + uses: actions/cache/restore@v4 + with: + path: ~/.cabal/store + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.deps }}-${{ env.branch }}- + ${{ steps.keys.outputs.deps }}- + + - name: Warn if dependencies not in cache + if: steps.restore-deps.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" + - name: Prepare cabal.project.local shell: bash run: | @@ -542,10 +739,21 @@ jobs: - name: Build postgres library shell: docker exec -t builder sh -eu {0} run: | - cabal clean cabal update scripts/desktop/build-lib-linux.sh postgres + - name: Compute dependency cache key + id: hash-deps + shell: bash + run: echo "hash=$(ls ~/.cabal/store/ghc-*/ | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Save dependencies to cache + if: steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + uses: actions/cache/save@v4 + with: + path: ~/.cabal/store + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} + - name: Copy libs from container shell: bash run: | @@ -565,12 +773,6 @@ jobs: files: postgres-libs/*.zip token: ${{ secrets.NODEJS_REPO_TOKEN }} - - name: Fix permissions for cache - shell: bash - run: | - sudo chmod -R 777 dist-newstyle ~/.cabal - sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal - # ========================= # MacOS Build # ========================= @@ -604,14 +806,49 @@ jobs: with: submodules: recursive + - name: Point Command Line Tools SDK to Xcode SDK + shell: bash + run: sudo ln -sfn "$(xcrun --sdk macosx --show-sdk-path)" /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk + + - name: Compute toolchain cache key + id: toolchain-key + if: env.release != 'true' + shell: bash + run: echo "key=ghcup-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-cabal3.10.2.0-$({ sw_vers -productVersion; clang --version; } | shasum -a 256 | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Restore toolchain from cache + id: restore-toolchain + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: | + ~/.ghcup/ghc/${{ matrix.ghc }} + ~/.ghcup/bin/*-${{ matrix.ghc }} + ~/.ghcup/bin/cabal-3.10.2.0 + key: ${{ steps.toolchain-key.outputs.key }} + + - name: Warn if toolchain not in cache + if: env.release != 'true' && steps.restore-toolchain.outputs.cache-hit != 'true' + run: echo "::warning title=Cache miss::${{ steps.toolchain-key.outputs.key }}" + - name: Prepare build + id: prepare uses: ./.github/actions/prepare-build with: java_ver: ${{ needs.variables.outputs.JAVA_VER }} ghc_ver: ${{ matrix.ghc }} - os: ${{ matrix.os }} github_ref: ${{ github.ref }} + - name: Save toolchain to cache + if: env.release != 'true' && steps.restore-toolchain.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.ghcup/ghc/${{ matrix.ghc }} + ~/.ghcup/bin/*-${{ matrix.ghc }} + ~/.ghcup/bin/cabal-3.10.2.0 + key: ${{ steps.toolchain-key.outputs.key }} + - name: Install OpenSSL run: brew install openssl@3.0 @@ -628,15 +865,100 @@ jobs: echo " extra-lib-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/lib" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local + - name: Compute cache key prefixes + id: keys + if: env.release != 'true' + shell: bash + run: | + toolchain=$({ sw_vers -productVersion; clang --version; } | shasum -a 256 | cut -c1-16) + target=${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-$toolchain + echo "deps=deps-$target" >> "$GITHUB_OUTPUT" + echo "dist=dist-$target" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies from cache + id: restore-deps + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: ${{ steps.prepare.outputs.cabal-store }} + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.deps }}-${{ env.branch }}- + ${{ steps.keys.outputs.deps }}- + + - name: Warn if dependencies not in cache + if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" + + - name: Restore source repositories from cache + id: restore-src + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + restore-keys: src-${{ runner.os }}- + + - name: Warn if source repositories not in cache + if: env.release != 'true' && steps.restore-src.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::src-${{ runner.os }}" + + - name: Restore build directory from cache + id: restore-build + if: env.release != 'true' && !needs.maybe-release.outputs.libs_tag + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-${{ env.opt }}- + + - name: Warn if build directory not in cache + if: steps.restore-build.outcome == 'success' && steps.restore-build.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}" + - name: Build CLI id: mac_cli_build shell: bash run: | - cabal build -j --enable-tests - path=$(cabal list-bin simplex-chat) + scripts/ci/prune-old-builds.sh dist-newstyle + cabal build -j --enable-tests ${{ env.ghc_options }} + path=$(cabal list-bin simplex-chat | tail -n 1) echo "bin_path=$path" >> $GITHUB_OUTPUT echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + - name: Compute dependency cache key + id: hash-deps + if: env.release != 'true' + shell: bash + run: echo "hash=$(ls '${{ steps.prepare.outputs.cabal-store }}'/ghc-*/ | shasum -a 256 | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Save dependencies to cache + if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + uses: actions/cache/save@v4 + with: + path: ${{ steps.prepare.outputs.cabal-store }} + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} + + - name: Save source repositories to cache + if: env.release != 'true' && matrix.arch == 'x86_64' && steps.restore-src.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + + - name: Save build directory to cache + if: env.release != 'true' && steps.restore-build.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') uses: ./.github/actions/prepare-release @@ -673,6 +995,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Run tests + if: matrix.arch == 'aarch64' timeout-minutes: 120 shell: bash run: | @@ -680,7 +1003,7 @@ jobs: attempts=1 ${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3 while [ "$i" -le "$attempts" ]; do - if cabal test --test-show-details=direct; then + if cabal test --test-show-details=direct ${{ env.ghc_options }}; then break else echo "Attempt $i failed, retrying..." @@ -713,6 +1036,42 @@ jobs: files: release-libs/*.zip token: ${{ secrets.NODEJS_REPO_TOKEN }} + - name: Remove -O2 build directory + id: remove-o2 + if: env.cache_o0 == 'true' && !cancelled() && steps.mac_cli_build.outcome == 'success' + shell: bash + run: find dist-newstyle -mindepth 1 -maxdepth 1 ! -name src -exec rm -rf {} + + + - name: Restore -O0 build directory from cache + id: restore-build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-O0- + + - name: Build at -O0 for pull request caches + id: build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + shell: bash + run: | + scripts/ci/prune-old-builds.sh dist-newstyle + cabal build -j --enable-tests --ghc-options=-O0 + + - name: Save -O0 build directory to cache + if: ${{ !cancelled() && steps.build-o0.outcome == 'success' && steps.restore-build-o0.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + # ========================= # Windows Build # ========================= @@ -739,12 +1098,11 @@ jobs: submodules: recursive - name: Prepare build + id: prepare uses: ./.github/actions/prepare-build with: java_ver: ${{ needs.variables.outputs.JAVA_VER }} ghc_ver: ${{ matrix.ghc }} - os: ${{ matrix.os }} - cache_path: "C:/cabal" github_ref: ${{ github.ref }} - name: Configure pagefile (Windows) @@ -768,6 +1126,61 @@ jobs: toolchain:p cmake:p + - name: Compute cache key prefixes + id: keys + if: env.release != 'true' + shell: msys2 {0} + run: | + toolchain=$(gcc --version | sha256sum | cut -c1-16) + target=windows-x86_64-ghc${{ matrix.ghc }}-$toolchain + echo "deps=deps-$target" >> "$GITHUB_OUTPUT" + echo "dist=dist-$target" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies from cache + id: restore-deps + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: ${{ steps.prepare.outputs.cabal-store }} + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.deps }}-${{ env.branch }}- + ${{ steps.keys.outputs.deps }}- + + - name: Warn if dependencies not in cache + if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" + + - name: Restore source repositories from cache + id: restore-src + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + restore-keys: src-${{ runner.os }}- + + - name: Warn if source repositories not in cache + if: env.release != 'true' && steps.restore-src.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::src-${{ runner.os }}" + + - name: Restore build directory from cache + id: restore-build + if: env.release != 'true' && !needs.maybe-release.outputs.libs_tag + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-${{ env.opt }}- + + - name: Warn if build directory not in cache + if: steps.restore-build.outcome == 'success' && steps.restore-build.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}" + # rm -rf dist-newstyle/src/{direct-sq,simplexmq}* is here because of the bug in cabal's dependency which prevents second build from finishing # (simplexmq is removed because cabal cannot delete its read-only git submodule pack files - blst, libbbs - on Windows) - name: Build CLI @@ -784,14 +1197,44 @@ jobs: echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local + scripts/ci/prune-old-builds.sh dist-newstyle rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* sed -i "s/, unix /--, unix /" simplex-chat.cabal - cabal build -j --enable-tests + cabal build -j --enable-tests ${{ env.ghc_options }} rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* path=$(cabal list-bin simplex-chat | tail -n 1) echo "bin_path=$path" >> $GITHUB_OUTPUT echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + - name: Compute dependency cache key + id: hash-deps + if: env.release != 'true' + shell: msys2 {0} + run: echo "hash=$(ls "$(cygpath -u '${{ steps.prepare.outputs.cabal-store }}')"/ghc-*/ | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Save dependencies to cache + if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + uses: actions/cache/save@v4 + with: + path: ${{ steps.prepare.outputs.cabal-store }} + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} + + - name: Save source repositories to cache + if: env.release != 'true' && steps.restore-src.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + + - name: Save build directory to cache + if: env.release != 'true' && steps.restore-build.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + - name: Build library if: needs.maybe-release.outputs.libs_tag shell: msys2 {0} @@ -848,6 +1291,46 @@ jobs: github_ref: ${{ github.ref }} github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Remove -O2 build directory + id: remove-o2 + if: env.cache_o0 == 'true' && !needs.maybe-release.outputs.libs_tag && !cancelled() && steps.windows_cli_build.outcome == 'success' + shell: msys2 {0} + run: find dist-newstyle -mindepth 1 -maxdepth 1 ! -name src -exec rm -rf {} + + + - name: Restore -O0 build directory from cache + id: restore-build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-O0- + + - name: Build at -O0 for pull request caches + id: build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + shell: msys2 {0} + run: | + export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) + scripts/desktop/prepare-openssl-windows.sh + scripts/ci/prune-old-builds.sh dist-newstyle + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* + cabal build -j --enable-tests --ghc-options=-O0 + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* + + - name: Save -O0 build directory to cache + if: ${{ !cancelled() && steps.build-o0.outcome == 'success' && steps.restore-build-o0.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + # ========================= # Packages release # ========================= diff --git a/plans/2026-10-15-ci-build-cache.md b/plans/2026-10-15-ci-build-cache.md new file mode 100644 index 0000000000..b6b6d34d17 --- /dev/null +++ b/plans/2026-10-15-ci-build-cache.md @@ -0,0 +1,243 @@ +# CI build cache + +Repository: simplex-chat/simplex-chat. +Branch: `ep/ci-build-cache`. +Model: simplexmq at `e91711c7`: +- `.github/workflows/build.yml` +- `scripts/ci/source-timestamps.sh` +- `scripts/ci/prune-old-builds.sh` + +Repository cache limit: 80 GB. + +## Files + +- `.github/workflows/build.yml` +- `.github/actions/prepare-build/action.yml` +- `scripts/ci/source-timestamps.sh`: copied from simplexmq. +- `scripts/ci/prune-old-builds.sh`: new. +- `scripts/desktop/build-lib-linux.sh`: build directory from `DIST_DIR`, default `dist-newstyle`. + +## Job environment + +Jobs: build-linux, build-linux-postgres, build-macos, build-windows. + +opt +- pull request: `O0` +- other events: `O2` + +ghc_options +- pull request: `--ghc-options=-O0` +- other events: empty + +release +- `v*` tag: `true` + +branch +- pull request: base branch +- other events: ref name + +cache_o0 +- push to master or stable: `true` + +ZSTD_CLEVEL +- `9`: zstd level of cache archives + +## Cache entries + +deps +- path: cabal store + - build-linux, build-linux-postgres: `~/.cabal/store` + - build-macos, build-windows: output `cabal-store` of prepare-build +- key: `deps---` +- restore key 1: `deps---` +- restore key 2: `deps--` +- saved when the store hash differs from the matched key + +toolchain +- job: build-macos +- path: + - `~/.ghcup/ghc/` + - `~/.ghcup/bin/*-` + - `~/.ghcup/bin/cabal-3.10.2.0` +- key: `ghcup---ghc-cabal3.10.2.0-` +- restored before "Prepare build" +- saved after "Prepare build" when the key is not matched + +src +- jobs: build-linux, build-macos, build-windows +- path: `dist-newstyle/src` +- key: `src--` +- restore key: `src--` +- saved when the matched key differs from the key, by: + - build-linux: ubuntu-22.04 x86_64, GHC 9.6.3 + - build-macos: x86_64 + - build-windows + +dist +- jobs: build-linux, build-macos, build-windows +- path: + - `dist-newstyle/*` + - `!dist-newstyle/src` +- key: `dist----` +- restore key 1: `dist----` +- restore key 2: `dist---` +- restored except on libs release pushes in build-macos and build-windows +- saved when the matched key differs from the key + +dist-lib +- job: build-linux +- path: + - `dist-lib/*` + - `!dist-lib/src` +- key: `dist-lib---` +- restore key 1: `dist-lib---` +- restore key 2: `dist-lib--` +- restored on pull requests that change the chat core, `libs` matrix entries, x86_64 +- saved when the library build succeeds and the matched key differs from the key + +store hash +- first 16 hex characters of sha256 of the store's package directory listing + +target +- build-linux: `ubuntu---ghc-` +- build-linux-postgres: `ubuntu-22.04-x86_64-postgres-ghc-` +- build-macos: `--ghc-` +- build-windows: `windows-x86_64-ghc-` + +toolchain +- Linux: first 16 hex characters of sha256 of the installed `binutils*`, `cpp*`, `gcc*` and `*-dev` package versions in the container +- macOS: first 16 hex characters of sha256 of `sw_vers -productVersion` and `clang --version` +- Windows: first 16 hex characters of sha256 of MSYS2 `gcc --version` + +Release tags: caches are neither restored nor saved. +Each missed restore: `::warning title=Cache miss::` annotation. + +## build-linux + +Job permissions +- `contents: write` +- `packages: write` + +Build image +- registry cache: `ghcr.io/simplex-chat/simplex-chat-build-cache:--ghc-` +- read: every build except release tags +- written: pushes to master, `mode=max`, `ignore-error=true` +- login: `docker login ghcr.io` with `GITHUB_TOKEN`, every build except release tags + +Container +- volume: `~/.cabal:/home/builder/.cabal` +- environment: `CABAL_DIR=/home/builder/.cabal` + +Steps after "Start container": +1. Compute cache keys. +2. Restore deps. +3. Restore src. +4. Restore dist. +5. Restore dist-lib. +6. GHC 8.10.7, dist restored: `scripts/ci/source-timestamps.sh restore dist-newstyle/sources.sha256`. +7. Prepare cabal.project.local: unchanged. +8. Build CLI: + - `scripts/ci/prune-old-builds.sh dist-newstyle` + - `cabal update` + - `cabal build -j --enable-tests ` + - binaries copied to `/out` + - `cabal clean` removed +9. Build library: + - `scripts/ci/prune-old-builds.sh dist-lib` + - `dist-lib/src`: symlink to `../dist-newstyle/src` + - `DIST_DIR=dist-lib` +10. GHC 8.10.7: `scripts/ci/source-timestamps.sh record dist-newstyle/sources.sha256`. +11. Save deps. +12. Save src. +13. Save dist. +14. Save dist-lib. +15. Release and test steps: unchanged. +16. -O0 refresh, when `cache_o0`, the run is not cancelled and "Build CLI" succeeded: + - remove `dist-newstyle` contents except `src` + - restore dist with opt `O0` + - GHC 8.10.7: restore source timestamps + - `scripts/ci/prune-old-builds.sh dist-newstyle` + - `cabal build -j --enable-tests --ghc-options=-O0` + - GHC 8.10.7: record source hashes + - save dist with opt `O0` + +"Fix permissions for cache": removed. + +## build-linux-postgres + +- condition: unchanged +- caches: deps +- container: volume and environment as in build-linux +- `cabal clean` removed +- "Fix permissions for cache": removed + +## build-macos and build-windows + +prepare-build action +- inputs `os` and `cache_path`: removed +- "Restore cached build": removed +- output `cabal-store`: output `cabal-store` of setup-haskell-action + +Steps: +1. build-macos: restore toolchain. +2. Prepare build. +3. build-macos: save toolchain. +4. Compute cache keys. +5. Restore deps. +6. Restore src. +7. Restore dist. +8. Build CLI: + - `scripts/ci/prune-old-builds.sh dist-newstyle` + - `` +9. Save deps. +10. Save src. +11. Save dist. +12. build-macos "Run tests": aarch64 only, `cabal test` with ``. +13. Other steps: unchanged. +14. -O0 refresh as in build-linux, after all other steps. + +build-macos +- step before "Prepare build": `/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk` linked to `xcrun --sdk macosx --show-sdk-path` +- Build CLI: `bin_path` from the last line of `cabal list-bin simplex-chat` + +build-windows +- cache key and store hash steps: `msys2` shell +- store hash: store path converted with `cygpath -u` +- keeps the `dist-newstyle/src` removals +- -O0 refresh: + - skipped on libs release pushes + - runs `scripts/desktop/prepare-openssl-windows.sh` before the build + +## prune-old-builds.sh + +Arguments: build directories. +In each `/build/*/*/`: +- `simplex-chat-`: deleted unless the version equals `simplex-chat.cabal` + +Version: carriage returns removed. + +## Verification + +Baseline, run 38730229359: +- Linux "Restore cached build": 0-4 s +- Linux "Build and cache Docker image": 3.5-5 min +- Linux "Build CLI", GHC 9.6.3: 23-30 min +- Linux "Build CLI", GHC 8.10.7: 80 min +- macOS "Build CLI": 1.5-30 min +- Windows "Build CLI": 23 min + +Test pull request: `ep/ci-build-cache` into master. + +On the second run of the test pull request: +1. build-linux, build-macos and build-windows restore deps and dist. +2. "Build CLI" compiles only modules changed since the first run. +3. GHC 8.10.7 "Build CLI" compiles only changed modules. +4. Tests pass. + +After merge, on the first push to master: +1. build-linux, build-macos and build-windows save deps, dist with opt `O2` and dist with opt `O0`. +2. build-linux writes the image cache. + +On the next pull request: +1. build-linux, build-macos and build-windows restore dist with opt `O0` from master. +2. build-linux reads the image cache. diff --git a/scripts/ci/prune-old-builds.sh b/scripts/ci/prune-old-builds.sh new file mode 100755 index 0000000000..520fb6bf45 --- /dev/null +++ b/scripts/ci/prune-old-builds.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +version=$(awk '/^version:/ {print $2}' simplex-chat.cabal | tr -d '\r') +for dir in "$@"; do + if [ -d "$dir/build" ]; then + find "$dir/build" -mindepth 3 -maxdepth 3 -type d -name 'simplex-chat-*' ! -name "simplex-chat-$version" -exec rm -rf {} + + fi +done diff --git a/scripts/ci/source-timestamps.sh b/scripts/ci/source-timestamps.sh new file mode 100755 index 0000000000..e8bc8b1cef --- /dev/null +++ b/scripts/ci/source-timestamps.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +command=$1 +hashes=$2 + +sources() { + git ls-files -z '*.hs' '*.hsc' +} + +case "$command" in + record) + sources | xargs -0 sha256sum > "$hashes" + ;; + restore) + sources | xargs -0 touch + while read -r hash file; do + if [ -f "$file" ] && [ "$(sha256sum "$file" | cut -d' ' -f1)" = "$hash" ]; then + touch -d 2000-01-01 "$file" + fi + done < "$hashes" + ;; + *) + echo "usage: $0 record|restore " >&2 + exit 1 + ;; +esac diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh index 6ee419c5a8..ea5fc9b760 100755 --- a/scripts/desktop/build-lib-linux.sh +++ b/scripts/desktop/build-lib-linux.sh @@ -19,7 +19,8 @@ fi root_dir="$(dirname "$(dirname "$(readlink "$0")")")" cd $root_dir -BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-${GHC_VERSION}/simplex-chat-* +DIST_DIR="${DIST_DIR:-dist-newstyle}" +BUILD_DIR=$DIST_DIR/build/$ARCH-$OS/ghc-${GHC_VERSION}/simplex-chat-* exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) ) for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done @@ -28,10 +29,10 @@ for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); #rm -rf $BUILD_DIR if [[ "$DATABASE_BACKEND" == "postgres" ]]; then echo "Building with postgres backend..." - cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' --constraint 'simplexmq +client_library +client_postgres' --constraint 'simplex-chat +client_library +client_postgres' + cabal build --builddir="$DIST_DIR" lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' --constraint 'simplexmq +client_library +client_postgres' --constraint 'simplex-chat +client_library +client_postgres' else echo "Building with sqlite backend..." - cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' + cabal build --builddir="$DIST_DIR" lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' fi cd $BUILD_DIR/build mv libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so libsimplex.so 2> /dev/null || true diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index dc8ad0f248..d7debec504 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1526,10 +1526,6 @@ Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) -Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? -Plan: -SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? Plan: SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 58c1c9c51c..5ae28df58a 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -6306,6 +6306,8 @@ testGroupHistoryGroupLink = bob #> "#team hey!" alice <# "#team bob> hey!" + threadDelay 1000000 + alice ##> "/create link #team" gLink <- getGroupLink alice "team" GRMember True From 77f9f4a464c25f2e262a5a60cd2abb5ef4a91f32 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 17 Oct 2026 19:23:53 +0100 Subject: [PATCH 02/10] core: add peer type "agent" (#7738) * add agent peer type * bot types * agent app icons * refactor * smaller image --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../SharedAssets.xcassets/Contents.json | 6 ++++++ .../agent.imageset/Contents.json | 16 +++++++++++++++ .../agent.imageset/agent.svg | 1 + .../Chat/ChatItem/CIChatLinkHeader.swift | 2 +- .../Chat/ChatItem/CIGroupInvitationView.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 4 ++-- .../ComposeMessage/ComposeChatLinkView.swift | 2 +- .../Views/Chat/Group/GroupProfileView.swift | 2 +- .../Views/ChatList/ChatPreviewView.swift | 2 +- .../Shared/Views/Helpers/ChatInfoImage.swift | 2 +- .../Shared/Views/Helpers/ProfileImage.swift | 4 ++-- .../Shared/Views/NewChat/AddChannelView.swift | 4 ++-- .../Shared/Views/NewChat/AddGroupView.swift | 2 +- .../Shared/Views/NewChat/NewChatView.swift | 20 +++++++++---------- .../Views/UserSettings/UserProfile.swift | 6 +++--- apps/ios/SimpleX SE/ShareView.swift | 6 +++--- apps/ios/SimpleX.xcodeproj/project.pbxproj | 6 ++++++ apps/ios/SimpleXChat/ChatTypes.swift | 15 ++++++++++++-- apps/ios/SimpleXChat/ChatUtils.swift | 13 ++++++------ .../chat/simplex/common/model/ChatModel.kt | 12 ++++++++++- .../chat/simplex/common/model/SimpleXAPI.kt | 2 +- .../common/views/newchat/ConnectPlan.kt | 3 +-- .../moko-resources/images/ic_agent.svg | 1 + bots/api/TYPES.md | 1 + docs/protocol/simplex-chat.schema.json | 2 +- .../types/typescript/src/types.ts | 1 + .../src/simplex_chat/types/_types.py | 2 +- src/Simplex/Chat/Types.hs | 4 +++- tests/ProtocolTests.hs | 3 +++ 29 files changed, 101 insertions(+), 45 deletions(-) create mode 100644 apps/ios/Shared/SharedAssets.xcassets/Contents.json create mode 100644 apps/ios/Shared/SharedAssets.xcassets/agent.imageset/Contents.json create mode 100644 apps/ios/Shared/SharedAssets.xcassets/agent.imageset/agent.svg create mode 100644 apps/multiplatform/common/src/commonMain/moko-resources/images/ic_agent.svg diff --git a/apps/ios/Shared/SharedAssets.xcassets/Contents.json b/apps/ios/Shared/SharedAssets.xcassets/Contents.json new file mode 100644 index 0000000000..4aa7c5350b --- /dev/null +++ b/apps/ios/Shared/SharedAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/Contents.json b/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/Contents.json new file mode 100644 index 0000000000..300553af5f --- /dev/null +++ b/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "agent.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} \ No newline at end of file diff --git a/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/agent.svg b/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/agent.svg new file mode 100644 index 0000000000..fd0fdb485a --- /dev/null +++ b/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/agent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift index aaaa29929d..1eebc5cf19 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift @@ -50,7 +50,7 @@ struct CIChatLinkHeader: View { HStack(alignment: .top) { ProfileImage( imageStr: chatLink.image, - iconName: chatLink.iconName, + icon: Image(systemName: chatLink.iconName), size: 44, color: Color(uiColor: .tertiaryLabel) ) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index fecd1ba891..efec44e943 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -115,7 +115,7 @@ struct CIGroupInvitationView: View { return HStack(alignment: .top) { ProfileImage( imageStr: groupInvitation.groupProfile.image, - iconName: "person.2.circle.fill", + icon: Image(systemName: "person.2.circle.fill"), size: 44, color: color ) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index d18d8ae145..29a1be3d65 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1968,7 +1968,7 @@ struct ChatView: View { .padding(.trailing, 12) } HStack(alignment: .top, spacing: 10) { - ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background) + ProfileImage(imageStr: groupInfo.image, icon: Image(systemName: groupInfo.chatIconName), size: memberImageSize, backgroundColor: theme.colors.background) .simultaneousGesture(TapGesture().onEnded { showChatInfoSheet = true }) @@ -2052,7 +2052,7 @@ struct ChatView: View { } HStack(alignment: .top, spacing: 10) { if ci.meta.showGroupAsSender { - ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background) + ProfileImage(imageStr: groupInfo.image, icon: Image(systemName: groupInfo.chatIconName), size: memberImageSize, backgroundColor: theme.colors.background) .simultaneousGesture(TapGesture().onEnded { showChatInfoSheet = true }) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift index 650ea8a87f..78f29c3b60 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift @@ -11,7 +11,7 @@ struct ComposeChatLinkView: View { HStack(alignment: .center, spacing: 8) { ProfileImage( imageStr: chatLink.image, - iconName: chatLink.iconName, + icon: Image(systemName: chatLink.iconName), size: 44 ) .padding(.leading, 12) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 126fcc57b3..19478753af 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -37,7 +37,7 @@ struct GroupProfileView: View { var body: some View { List { - EditProfileImage(profileImage: $groupProfile.image, iconName: groupInfo.chatIconName, showChooseSource: $showChooseSource) + EditProfileImage(profileImage: $groupProfile.image, icon: Image(systemName: groupInfo.chatIconName), showChooseSource: $showChooseSource) .if(!focusDisplayName) { $0.padding(.top) } Section { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 5ee991a466..8156ea81c7 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -444,7 +444,7 @@ struct ChatPreviewView: View { smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) { ProfileImage( imageStr: chatLink.image, - iconName: chatLink.iconName, + icon: Image(systemName: chatLink.iconName), size: dynamicMediaSize, color: Color(uiColor: .tertiaryLabel) ) diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift index 40d62e009b..a0f21aef9b 100644 --- a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift +++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift @@ -19,7 +19,7 @@ struct ChatInfoImage: View { let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color } return ProfileImage( imageStr: chat.chatInfo.image, - iconName: chatIconName(chat.chatInfo), + icon: chatIcon(chat.chatInfo), size: size, color: iconColor ) diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift index 9c2916880c..05b4a06967 100644 --- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift +++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift @@ -12,7 +12,7 @@ import SimpleXChat struct ProfileImage: View { @EnvironmentObject var theme: AppTheme var imageStr: String? = nil - var iconName: String = "person.crop.circle.fill" + var icon: Image = Image(systemName: "person.crop.circle.fill") var size: CGFloat var color = Color(uiColor: .tertiarySystemGroupedBackground) var backgroundColor: Color? = nil @@ -24,7 +24,7 @@ struct ProfileImage: View { clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred) } else { let c = color.asAnotherColorFromSecondaryVariant(theme) - Image(systemName: iconName) + icon .resizable() .foregroundColor(c) .scaledToFit() diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index 7d1e5ce827..fa953b2ec1 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -50,7 +50,7 @@ struct AddChannelView: View { Spacer(minLength: 0) ZStack(alignment: .center) { ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: profile.image, iconName: "antenna.radiowaves.left.and.right.circle.fill", size: 128) + ProfileImage(imageStr: profile.image, icon: Image(systemName: "antenna.radiowaves.left.and.right.circle.fill"), size: 128) if profile.image != nil { Button { profile.image = nil @@ -285,7 +285,7 @@ struct AddChannelView: View { let total = groupRelays.count return List { Group { - ProfileImage(imageStr: gInfo.groupProfile.image, iconName: "antenna.radiowaves.left.and.right.circle.fill", size: 128) + ProfileImage(imageStr: gInfo.groupProfile.image, icon: Image(systemName: "antenna.radiowaves.left.and.right.circle.fill"), size: 128) .frame(maxWidth: .infinity, alignment: .center) Text(gInfo.groupProfile.displayName) diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 47afee5f06..a4f02e51ba 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -71,7 +71,7 @@ struct AddGroupView: View { Spacer(minLength: 0) ZStack(alignment: .center) { ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: profile.image, iconName: "person.2.circle.fill", size: 128) + ProfileImage(imageStr: profile.image, icon: Image(systemName: "person.2.circle.fill"), size: 128) if profile.image != nil { Button { profile.image = nil diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index ef691b10f9..b558802d36 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1129,11 +1129,9 @@ private func showPrepareContactAlert( profileImage: ProfileImage( imageStr: contactShortLinkData.profile.image, - iconName: contactShortLinkData.business - ? "briefcase.circle.fill" - : contactShortLinkData.profile.peerType == .bot - ? "cube.fill" - : "person.crop.circle.fill", + icon: contactShortLinkData.business + ? Image(systemName: "briefcase.circle.fill") + : peerTypeIcon(contactShortLinkData.profile.peerType), size: alertProfileImageSize ), profileBadge: contactShortLinkData.localBadge, @@ -1186,9 +1184,9 @@ private func showPrepareGroupAlert( profileImage: ProfileImage( imageStr: groupShortLinkData.groupProfile.image, - iconName: isChannel + icon: Image(systemName: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" - : "person.2.circle.fill", + : "person.2.circle.fill"), size: alertProfileImageSize ), theme: theme, @@ -1240,7 +1238,7 @@ private func showOpenKnownContactAlert( profileImage: ProfileImage( imageStr: contact.profile.image, - iconName: contact.chatIconName, + icon: contact.chatIcon, size: alertProfileImageSize ), profileBadge: contact.active ? contact.profile.localBadge : nil, @@ -1288,7 +1286,7 @@ private func showOpenKnownGroupAlert( profileImage: ProfileImage( imageStr: groupInfo.groupProfile.image, - iconName: groupInfo.chatIconName, + icon: Image(systemName: groupInfo.chatIconName), size: alertProfileImageSize ), theme: theme, @@ -1605,7 +1603,7 @@ func planAndConnect( profileImage: ProfileImage( imageStr: groupSLinkData.groupProfile.image, - iconName: "antenna.radiowaves.left.and.right.circle.fill", + icon: Image(systemName: "antenna.radiowaves.left.and.right.circle.fill"), size: alertProfileImageSize ), theme: theme, @@ -1632,7 +1630,7 @@ func planAndConnect( profileImage: ProfileImage( imageStr: groupSLinkData.groupProfile.image, - iconName: "person.2.circle.fill", + icon: Image(systemName: "person.2.circle.fill"), size: alertProfileImageSize ), theme: theme, diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 8fe8a1c45a..64c877f9cd 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -29,7 +29,7 @@ struct UserProfile: View { var body: some View { List { - EditProfileImage(profileImage: $profile.image, iconName: "person.crop.circle.fill", showChooseSource: $showChooseSource) + EditProfileImage(profileImage: $profile.image, icon: Image(systemName: "person.crop.circle.fill"), showChooseSource: $showChooseSource) .padding(.top) Section { @@ -221,7 +221,7 @@ struct EditProfileImage: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner @Binding var profileImage: String? - var iconName: String + var icon: Image @Binding var showChooseSource: Bool var body: some View { @@ -237,7 +237,7 @@ struct EditProfileImage: View { } } else { ZStack(alignment: .center) { - ProfileImage(imageStr: profileImage, iconName: iconName, size: 160) + ProfileImage(imageStr: profileImage, icon: icon, size: 160) editImageButton { showChooseSource = true } } } diff --git a/apps/ios/SimpleX SE/ShareView.swift b/apps/ios/SimpleX SE/ShareView.swift index 3bdbc950ba..fd163f74e5 100644 --- a/apps/ios/SimpleX SE/ShareView.swift +++ b/apps/ios/SimpleX SE/ShareView.swift @@ -25,7 +25,7 @@ struct ShareView: View { HStack { profileImage( chatInfoId: chat.chatInfo.id, - iconName: chatIconName(chat.chatInfo), + icon: chatIcon(chat.chatInfo), size: 30 ) Text(chat.chatInfo.displayName).foregroundStyle( @@ -209,11 +209,11 @@ struct ShareView: View { .background(Material.ultraThin) } - @ViewBuilder private func profileImage(chatInfoId: ChatInfo.ID, iconName: String, size: Double) -> some View { + @ViewBuilder private func profileImage(chatInfoId: ChatInfo.ID, icon: Image, size: Double) -> some View { if let uiImage = model.profileImages[chatInfoId] { clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius) } else { - Image(systemName: iconName) + icon .resizable() .foregroundColor(Color(uiColor: .tertiaryLabel)) .frame(width: size, height: size) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index fb29f753b0..389017cce0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -271,6 +271,8 @@ E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */; }; E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; + E5C0BBEB2F82B45500EA7527 /* SharedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */; }; + E5C0BBEC2F82B45500EA7527 /* SharedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */; }; E5DBF1932F88169800E1D7FD /* ConnectBannerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */; }; E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; }; @@ -662,6 +664,7 @@ E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = ""; }; E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = ""; }; E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = ""; }; + E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SharedAssets.xcassets; sourceTree = ""; }; E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectBannerCard.swift; sourceTree = ""; }; @@ -953,6 +956,7 @@ 5C2E260D27A30E2400F70299 /* Views */, 5CA059C5279559F40002BEB4 /* Assets.xcassets */, E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */, + E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */, D7AA2C3429A936B400737B40 /* MediaEncryption.playground */, 5C13730C2815740A00F43030 /* DebugJSON.playground */, ); @@ -1506,6 +1510,7 @@ 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */, + E5C0BBEB2F82B45500EA7527 /* SharedAssets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1538,6 +1543,7 @@ files = ( E5DCF9712C590272007928CC /* Localizable.strings in Resources */, E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */, + E5C0BBEC2F82B45500EA7527 /* SharedAssets.xcassets in Resources */, E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a27f396a54..a4d65a6d10 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -228,6 +228,7 @@ public enum ChatPeerType: Hashable { case human case bot case business + case agent case unknown(String) public var text: String { @@ -235,6 +236,7 @@ public enum ChatPeerType: Hashable { case .human: "human" case .bot: "bot" case .business: "business" + case .agent: "agent" case let .unknown(s): s } } @@ -246,6 +248,7 @@ extension ChatPeerType: Codable { case "human": self = .human case "bot": self = .bot case "business": self = .business + case "agent": self = .agent case let s: self = .unknown(s) } } @@ -256,6 +259,14 @@ extension ChatPeerType: Codable { } } +public func peerTypeIcon(_ peerType: ChatPeerType?) -> Image { + switch peerType { + case .bot: Image(systemName: "cube.fill") + case .agent: Image("agent") + default: Image(systemName: "person.crop.circle.fill") + } +} + // Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. // Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. public enum BadgeType: Hashable { @@ -2532,8 +2543,8 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { activeConn?.customUserProfileId != nil } - public var chatIconName: String { - isBot ? "cube.fill" : "person.crop.circle.fill" + public var chatIcon: Image { + peerTypeIcon(profile.peerType) } public func allowsFeature(_ feature: ChatFeature) -> Bool { diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 45320c5b93..ab9cad9de6 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -7,6 +7,7 @@ // import Foundation +import SwiftUI public protocol ChatLike { var chatInfo: ChatInfo { get } @@ -91,13 +92,13 @@ private func canForwardToChat(_ cInfo: ChatInfo) -> Bool { } } -public func chatIconName(_ cInfo: ChatInfo) -> String { +public func chatIcon(_ cInfo: ChatInfo) -> Image { switch cInfo { - case let .direct(contact): contact.chatIconName - case let .group(groupInfo, _): groupInfo.chatIconName - case .local: "folder.circle.fill" - case .contactRequest: "person.crop.circle.fill" - default: "circle.fill" + case let .direct(contact): contact.chatIcon + case let .group(groupInfo, _): Image(systemName: groupInfo.chatIconName) + case .local: Image(systemName: "folder.circle.fill") + case .contactRequest: Image(systemName: "person.crop.circle.fill") + default: Image(systemName: "circle.fill") } } 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 3db735ecea..bf856c13af 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 @@ -1954,7 +1954,7 @@ data class Contact( activeConn?.customUserProfileId != null val chatIconName: ImageResource get() = - if (isBot) MR.images.ic_cube else MR.images.ic_account_circle_filled + peerTypeIcon(profile.peerType) fun allowsFeature(feature: ChatFeature): Boolean = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO @@ -2165,6 +2165,7 @@ sealed class ChatPeerType { @Serializable @SerialName("human") object Human: ChatPeerType() @Serializable @SerialName("bot") object Bot: ChatPeerType() @Serializable @SerialName("business") object Business: ChatPeerType() + @Serializable @SerialName("agent") object Agent: ChatPeerType() @Serializable @SerialName("unknown") data class Unknown(val type: String): ChatPeerType() val text: String @@ -2172,6 +2173,7 @@ sealed class ChatPeerType { is Human -> "human" is Bot -> "bot" is Business -> "business" + is Agent -> "agent" is Unknown -> type } } @@ -2183,11 +2185,19 @@ object ChatPeerTypeSerializer : KSerializer { "human" -> ChatPeerType.Human "bot" -> ChatPeerType.Bot "business" -> ChatPeerType.Business + "agent" -> ChatPeerType.Agent else -> ChatPeerType.Unknown(v) } override fun serialize(encoder: Encoder, value: ChatPeerType) = encoder.encodeString(value.text) } +fun peerTypeIcon(peerType: ChatPeerType?): ImageResource = + when (peerType) { + ChatPeerType.Bot -> MR.images.ic_cube + ChatPeerType.Agent -> MR.images.ic_agent + else -> MR.images.ic_account_circle_filled + } + // Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. // Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. @Serializable(with = BadgeTypeSerializer::class) 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 785965eff4..54d684160e 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 @@ -8139,7 +8139,7 @@ sealed class StoreError { is UserChatRelayNotFound -> "userChatRelayNotFound $chatRelayId" is GroupRelayNotFound -> "groupRelayNotFound $groupRelayId" is GroupRelayNotFoundByMemberId -> "groupRelayNotFoundByMemberId $groupMemberId" - is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $authorGroupMemberId" + is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $forwardedByGroupMemberId" is RemoteHostNotFound -> "remoteHostNotFound $remoteHostId" is RemoteHostUnknown -> "remoteHostUnknown" is RemoteHostDuplicateCA -> "remoteHostDuplicateCA" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 61cb73ca0b..8b145ef206 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -717,8 +717,7 @@ fun showPrepareContactAlert( image = contactShortLinkData.profile.image, icon = if (contactShortLinkData.business) MR.images.ic_work_filled_padded - else if (contactShortLinkData.profile.peerType == ChatPeerType.Bot) MR.images.ic_cube - else MR.images.ic_account_circle_filled + else peerTypeIcon(contactShortLinkData.profile.peerType) ) }, profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge, diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_agent.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_agent.svg new file mode 100644 index 0000000000..e03226d798 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_agent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index fe79d847e2..6ca0d67ac4 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -1545,6 +1545,7 @@ Search: - "human" - "bot" - "business" +- "agent" --- diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index 4e9c3c73a0..5fb4794094 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -25,7 +25,7 @@ "metadata": {"comment": "profile bio (160 characters)"} }, "contactLink": {"ref": "connReqUri"}, - "peerType": {"enum": ["human", "bot"]}, + "peerType": {"enum": ["human", "bot", "agent"]}, "preferences": { "optionalProperties": { "commands": { diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index cebc68abb4..35711c62cf 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -1796,6 +1796,7 @@ export enum ChatPeerType { Human = "human", Bot = "bot", Business = "business", + Agent = "agent", } // Used in API commands. Chat scope can only be passed with groups. diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 1befa750c3..f732d7faf2 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -1247,7 +1247,7 @@ ChatListQuery = ChatListQuery_filters | ChatListQuery_search ChatListQuery_Tag = Literal["filters", "search"] -ChatPeerType = Literal["human", "bot", "business"] +ChatPeerType = Literal["human", "bot", "business", "agent"] # Used in API commands. Chat scope can only be passed with groups. diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 81ed6ac5a8..94fd5d4fcd 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -749,7 +749,7 @@ data Profile = Profile } deriving (Eq, Show) -data ChatPeerType = CPTHuman | CPTBot | CPTBusiness | CPTUnknown Text +data ChatPeerType = CPTHuman | CPTBot | CPTBusiness | CPTAgent | CPTUnknown Text deriving (Eq, Show) instance FromJSON ChatPeerType where @@ -768,11 +768,13 @@ instance TextEncoding ChatPeerType where "human" -> CPTHuman "bot" -> CPTBot "business" -> CPTBusiness + "agent" -> CPTAgent tag -> CPTUnknown tag textEncode = \case CPTHuman -> "human" CPTBot -> "bot" CPTBusiness -> "business" + CPTAgent -> "agent" CPTUnknown tag -> tag profileFromName :: ContactName -> Profile diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 35f1ea5885..fb9e8317c3 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -332,6 +332,9 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.info with empty full name" $ "{\"v\":\"9\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing} Nothing + it "x.info with agent peer type" $ + "{\"v\":\"9\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"peerType\":\"agent\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + #==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTAgent, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing} Nothing it "x.contact with xContactId" $ "{\"v\":\"9\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile Nothing (Just $ XContactId "\1\2\3\4") Nothing Nothing From 139ce5f8cc900a75f5f7020341d2fc2ff2d2748f Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 17 Oct 2026 19:49:30 +0100 Subject: [PATCH 03/10] core: fix profile creation and update in channels (#7740) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- src/Simplex/Chat/Store/ContactRequest.hs | 9 ++-- src/Simplex/Chat/Store/Direct.hs | 6 +-- src/Simplex/Chat/Store/Groups.hs | 12 ++--- .../SQLite/Migrations/chat_query_plans.txt | 18 ++++--- tests/ChatTests/Groups.hs | 48 +++++++++++++++++++ 5 files changed, 74 insertions(+), 19 deletions(-) diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 35a5138ed1..7db176bd50 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -76,7 +76,7 @@ createOrUpdateContactRequest invId cReqChatVRange@(VersionRange minV maxV) presHeader_ - profile@Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences} + profile@Profile {displayName, fullName, shortDescr, description, image, contactLink, peerType, preferences} xContactId_ welcomeMsgId_ requestMsg_ @@ -174,8 +174,8 @@ createOrUpdateContactRequest liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, description, image, contactLink, userId) :. ("" :: LocalAlias, currentTs, currentTs) :. badgeToRow badge badgeVerified :. prefsToRow preferences) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, description, image, contactLink, peerType, userId) :. ("" :: LocalAlias, currentTs, currentTs) :. badgeToRow badge badgeVerified :. prefsToRow preferences) profileId <- liftIO $ insertedRowId db liftIO $ DB.execute @@ -247,6 +247,7 @@ createOrUpdateContactRequest description = ?, image = ?, contact_link = ?, + chat_peer_type = ?, updated_at = ?, badge_proof = ?, badge_pres_header = ?, @@ -264,7 +265,7 @@ createOrUpdateContactRequest AND contact_request_id = ? ) |] - ((displayName, fullName, shortDescr, description, image, contactLink, currentTs) :. badgeToRow badge badgeVerified :. (userId, contactRequestId)) + ((displayName, fullName, shortDescr, description, image, contactLink, peerType, currentTs) :. badgeToRow badge badgeVerified :. (userId, contactRequestId)) updateRequest currentTs = if displayName == oldDisplayName then diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 462b3629f1..67ba030ee1 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -752,17 +752,17 @@ updateMemberContactProfileReset_ db userId profileId profile badgeVerified = do updateMemberContactProfileReset_' db userId profileId profile badgeVerified currentTs updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO () -updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, description, image, contactDomain, badge} badgeVerified updatedAt = +updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, description, image, contactLink, peerType, contactDomain, badge} badgeVerified updatedAt = DB.execute db [sql| UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = NULL, preferences = NULL, preferences_json = NULL, updated_at = ?, + SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, chat_peer_type = ?, preferences = NULL, preferences_json = NULL, updated_at = ?, badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? |] - ((displayName, fullName, shortDescr, description, image, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId)) + ((displayName, fullName, shortDescr, description, image, contactLink, peerType, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId)) -- update only member profile fields (when member has associated contact - we keep contactLink and prefs) updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO () diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index e9d99fbe89..839a3d5746 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -2127,7 +2127,7 @@ createJoiningMember User {userId, userContactId} GroupInfo {groupId, membership} cReqChatVRange - p@Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences} + p@Profile {displayName, fullName, shortDescr, description, image, contactLink, peerType, preferences} presHeader_ cReqXContactId_ cReqMemberId_ @@ -2141,8 +2141,8 @@ createJoiningMember liftIO $ DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, description, image, contactLink, userId, currentTs, currentTs) :. badgeToRow badge badgeVerified :. prefsToRow preferences) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, description, image, contactLink, peerType, userId, currentTs, currentTs) :. badgeToRow badge badgeVerified :. prefsToRow preferences) profileId <- liftIO $ insertedRowId db r@(groupMemberId, _) <- case cReqMemberId_ of Just memberId -> do @@ -2500,13 +2500,13 @@ createNewGroupMember db cxt user gInfo invitingMember memInfo@MemberInfo {profil createNewMember_ db user (groupId' gInfo) newMember badgeVerified currentTs createNewMemberProfile_ :: DB.Connection -> StoreCxt -> User -> Profile -> Maybe ProofPresHeader -> UTCTime -> ExceptT StoreError IO (Text, ProfileId, Profile, Maybe Bool) -createNewMemberProfile_ db cxt User {userId} p@Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences} presHeader_ createdAt = +createNewMemberProfile_ db cxt User {userId} p@Profile {displayName, fullName, shortDescr, description, image, contactLink, peerType, preferences} presHeader_ createdAt = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do (p'@Profile {badge}, badgeVerified) <- profileBadgeVerified presHeader_ (badgeKeys cxt) Nothing p DB.execute db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, description, image, contactLink, userId, createdAt, createdAt) :. badgeToRow badge badgeVerified :. prefsToRow preferences) + "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, description, image, contactLink, peerType, userId, createdAt, createdAt) :. badgeToRow badge badgeVerified :. prefsToRow preferences) profileId <- insertedRowId db pure $ Right (ldn, profileId, p', badgeVerified) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 17a48e59d6..7f08ffda73 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -507,6 +507,7 @@ Query: description = ?, image = ?, contact_link = ?, + chat_peer_type = ?, updated_at = ?, badge_proof = ?, badge_pres_header = ?, @@ -5463,7 +5464,7 @@ SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = NULL, preferences = NULL, preferences_json = NULL, updated_at = ?, + SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, chat_peer_type = ?, preferences = NULL, preferences_json = NULL, updated_at = ?, badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? @@ -7358,15 +7359,15 @@ Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, descr Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Plan: +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) + Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, contact_domain, contact_domain_proof, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) -Plan: -SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) - -Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) @@ -7930,6 +7931,11 @@ Query: SELECT notification FROM delivery_jobs ORDER BY delivery_job_id DESC LIMI Plan: SCAN delivery_jobs +Query: SELECT p.contact_link, p.chat_peer_type FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id WHERE m.local_display_name = ? +Plan: +SCAN m +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT purchase_key FROM badge_code_redemptions Plan: SCAN badge_code_redemptions diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 5ae28df58a..521fc44d9a 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -305,6 +305,7 @@ chatGroupTests = do it "multiple large profiles pack across batches in one multi-sender job" testChannelMultipleLargeProfiles it "overflow profile batch is sent without notification flag" testChannelOverflowProfileNotification it "profile update reuses existing announcement (no re-prepend)" testChannelProfileUpdateNoRePrepend + it "owner, relay and subscriber store address and peer type of promoted member" testChannelMemberAddressPeerType describe "channel operations" $ do it "should update channel profile (signed)" testChannelUpdateProfileSigned it "should preserve working link after profile update" testChannelLinkAfterProfileUpdate @@ -10234,6 +10235,53 @@ testChannelProfileUpdateNoRePrepend ps = threadDelay 500000 memberIntroducedTo bob "kate" "dan" +testChannelMemberAddressPeerType :: HasCallStack => TestParams -> IO () +testChannelMemberAddressPeerType ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathBotProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + _ <- setupRelay alice bob + alice ##> "/_public group 1 1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"directMessages\": {\"enable\": \"on\"}, \"support\": {\"enable\": \"off\"}}}" + alice <## "group #team is created" + alice <## "wait for selected relay(s) to join, then you can invite members via group link" + concurrentlyN_ + [ do + alice <## "#team: group link relays updated, current relays:" + alice <## " - relay id 1: active" + alice <## "group link:" + void $ getTermLine alice, + bob <## "#team: you joined the group as relay" + ] + alice ##> "/show link #team" + (shortLink, fullLink) <- getGroupLinks alice "team" GRObserver False + + cath ##> "/ad" + _ <- getContactLinks cath True + cath ##> "/pa on" + cath <## "new contact address set" + + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] + + cath #> "#team hi from cath" + bob <# "#team cath> hi from cath" + alice <# "#team cath> hi from cath [>>]" + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi from cath [>>]" + + Just cathAddress <- withCCUser cath $ \User {profile = LocalProfile {contactLink}} -> pure contactLink + mapM (`memberAddressPeerType` "cath") [alice, bob, dan] `shouldReturn` replicate 3 [(Just cathAddress, Just CPTBot)] + where + cathBotProfile = (cathProfile :: Profile) {peerType = Just CPTBot} + +memberAddressPeerType :: TestCC -> T.Text -> IO [(Maybe ConnLinkContact, Maybe ChatPeerType)] +memberAddressPeerType cc name = + withCCTransaction cc $ \db -> + DB.query db "SELECT p.contact_link, p.chat_peer_type FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id WHERE m.local_display_name = ?" (Only name) + testChannelMultiSendersIndependent :: HasCallStack => TestParams -> IO () testChannelMultiSendersIndependent ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do From 1d0247b7ddd6c53f3fd51f3d94cec3cb9656ec30 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 18 Oct 2026 08:51:33 +0100 Subject: [PATCH 04/10] core: preserve profile peer type when connecting incognito (#7751) * core: preserve profile peer type when connecting incognito * Apply batched suggestions from code review Co-authored-by: Evgeny * fix --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- src/Simplex/Chat/Library/Commands.hs | 16 ++++----- src/Simplex/Chat/Library/Internal.hs | 7 ++-- src/Simplex/Chat/Library/Subscriber.hs | 3 +- src/Simplex/Chat/ProfileGenerator.hs | 9 ++--- .../SQLite/Migrations/chat_query_plans.txt | 4 +-- src/Simplex/Chat/Store/Shared.hs | 8 ++--- tests/ChatTests/Profiles.hs | 34 ++++++++++++++++++- 7 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index fee1a8e4d9..07839dd032 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -462,7 +462,7 @@ processChatCommand cxt nm = \case checkValidName displayName checkProfileImageSize image checkProfileSize p - p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile + p@Profile {displayName} <- liftIO $ maybe (generateRandomProfile Nothing) pure profile u <- asks currentUser users <- withFastStore' getUsers forM_ users $ \User {profile = LocalProfile {displayName = n}, activeUser, viewPwdHash, userChatRelay = userChatRelay'} -> do @@ -2132,7 +2132,7 @@ processChatCommand cxt nm = \case Welcome -> withUser $ pure . CRWelcome APIAddContact userId incognito -> withUserId userId $ \user -> do -- [incognito] generate profile for connection - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing subMode <- chatReadVar subscriptionMode (preparedReq, preparedParams@PreparedLinkParams {plpLinkKey = LinkKey linkKey}) <- withAgent $ \a -> prepareInvitationLink a (aUserId user) IKUsePQ Nothing linkProfile <- presentUserBadge user incognitoProfile (Just $ PHLink linkKey) $ userProfileDirect user incognitoProfile Nothing True @@ -2150,7 +2150,7 @@ processChatCommand cxt nm = \case let PendingContactConnection {pccConnStatus, customUserProfileId, connLinkInv} = conn case (pccConnStatus, customUserProfileId, incognito) of (ConnNew, Nothing, True) -> do - incognitoProfile <- liftIO generateRandomProfile + incognitoProfile <- liftIO $ generateIncognitoProfile user sLnk <- updatePCCShortLinkData conn $ userProfileDirect user (Just incognitoProfile) Nothing True conn' <- withFastStore' $ \db -> do pId <- createIncognitoProfile db user incognitoProfile @@ -2329,7 +2329,7 @@ processChatCommand cxt nm = \case publicMemberCount_ = (\PublicGroupData {publicMemberCount} -> publicMemberCount) <$> publicGroupData_ -- Prepare group record once before connecting to relays (updatePreparedRelayedGroup): -- set group link info and incognito profile, generate and store membership keys - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing let cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} e2e g'@(GIK gInfo' _) <- withFastStore $ \db -> do g'@(GIK gInfo' _) <- updatePreparedRelayedGroup db cxt user gInfo mainCReq cReqHash incognitoProfile rootKey publicMemberCount_ @@ -3886,7 +3886,7 @@ processChatCommand cxt nm = \case pqSup' = PQSupportOn joinNewConn chatV = do -- [incognito] generate profile to send - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing (connId, binding) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup' let ccLink = CCLink cReq $ serverShortLink <$> sLnk_ conn <- withFastStore' $ \db -> createDirectConnection' db userId connId ccLink contactId_ ConnPrepared incognitoProfile subMode chatV pqSup' @@ -3958,7 +3958,7 @@ processChatCommand cxt nm = \case -- [incognito] generate profile to send, or use membership profile for relay groups incognitoProfile_ <- case gInfo_ of Just (Just (GIK gInfo _)) | useRelays' gInfo -> pure $ ExistingIncognito <$> incognitoMembershipProfile gInfo - _ -> if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + _ -> if incognito then Just . NewIncognito <$> liftIO (generateIncognitoProfile user) else pure Nothing let incognitoProfile = fromIncognitoProfile <$> incognitoProfile_ subMode <- chatReadVar subscriptionMode let sLnk' = serverShortLink <$> sLnk @@ -3974,7 +3974,7 @@ processChatCommand cxt nm = \case (connId, chatV, reqHeader) <- prepareContact user cReq pqSup newXContactId <- XContactId <$> drgRandomBytes 16 -- [incognito] generate profile to send - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing subMode <- chatReadVar subscriptionMode let cReqHash = contactCReqHash cReq conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq reqHeader cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup @@ -4345,7 +4345,7 @@ processChatCommand cxt nm = \case when (isPublicGroup groupKeys && isJust (memberAdmission >>= review)) $ throwCmdError "Admission review is not supported in channels" when (not (isPublicGroup groupKeys) && isJust publicGroup) $ throwCmdError "publicGroup is not allowed in groups" -- [incognito] generate incognito profile for group membership - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just <$> liftIO (generateIncognitoProfile user) else pure Nothing withFastStore $ \db -> createNewGroup db cxt user gProfile incognitoProfile memberId groupKeys publicMemberCount_ createNewGroupItems :: User -> GroupInfo -> CM () createNewGroupItems user gInfo = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 418d454ada..ced5c8c676 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -994,7 +994,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId let chatV = vr cxt `peerConnChatVersion` cReqChatVRange (ct, conn, incognitoProfile, getPresHeader) <- case contactId_ of Nothing -> do - incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO (generateIncognitoProfile user) else pure Nothing (connId, binding) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' (ct, conn) <- withStore' $ \db -> createContactFromRequest db user userContactLinkId_ connId chatV cReqChatVRange cName profileId cp xContactId incognitoProfile subMode pqSup' False @@ -1003,7 +1003,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId ct <- withFastStore $ \db -> getContact db cxt user contactId case contactConn ct of Nothing -> do - incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO (generateIncognitoProfile user) else pure Nothing (connId, binding) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' currentTs <- liftIO getCurrentTime conn <- withStore' $ \db -> do @@ -1336,6 +1336,9 @@ introduceInChannel cxt user g@(GIK gInfo _) subscriber@GroupMember {activeConn = sendIntros ms = forM_ (L.nonEmpty $ map (memberIntroEvt gInfo) ms) $ \evts -> sendGroupMemberMessages user g conn evts +generateIncognitoProfile :: User -> IO Profile +generateIncognitoProfile User {profile = LocalProfile {peerType}} = generateRandomProfile peerType + userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user g = userProfileInGroup' user (Just g) {-# INLINE userProfileInGroup #-} diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 9047ca1532..ead232b3be 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -57,7 +57,6 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events -import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Connections @@ -1535,7 +1534,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag Just AutoAccept {acceptIncognito} -> do incognitoProfile <- if not shortLinkDataSet && acceptIncognito - then Just . NewIncognito <$> liftIO generateRandomProfile + then Just . NewIncognito <$> liftIO (generateIncognitoProfile user) else pure Nothing ct' <- acceptContactRequestAsync user uclId ct ucr incognitoProfile toView $ CEvtAcceptingContactRequest user ct' diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs index b460a73d3d..bc1d069540 100644 --- a/src/Simplex/Chat/ProfileGenerator.hs +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -1,16 +1,17 @@ +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.ProfileGenerator where import Data.Text (Text) -import Simplex.Chat.Types (Profile (..)) +import Simplex.Chat.Types (ChatPeerType, Profile (..)) import System.Random (randomRIO) -generateRandomProfile :: IO Profile -generateRandomProfile = do +generateRandomProfile :: Maybe ChatPeerType -> IO Profile +generateRandomProfile peerType = do adjective <- pick adjectives noun <- pickNoun adjective 2 - pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing} + pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType, preferences = Nothing, badge = Nothing, contactDomain = Nothing} where pick :: [a] -> IO a pick xs = (xs !!) <$> randomRIO (0, length xs - 1) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 7f08ffda73..fefef85c38 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -5108,8 +5108,8 @@ Query: Plan: Query: - INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, user_id, incognito, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?) + INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, chat_peer_type, user_id, incognito, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) Plan: SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 75a49090ed..bf41d2de6e 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -323,14 +323,14 @@ createConnection_ db userId connType entityId acId connStatus connChatVersion pe ent ct = if connType == ct then entityId else Nothing createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Profile -> IO Int64 -createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, shortDescr, description, image} = do +createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, shortDescr, description, image, peerType} = do DB.execute db [sql| - INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, user_id, incognito, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?) + INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, chat_peer_type, user_id, incognito, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) |] - (displayName, fullName, shortDescr, description, image, userId, Just (BI True), createdAt, createdAt) + (displayName, fullName, shortDescr, description, image, peerType, userId, Just (BI True), createdAt, createdAt) insertedRowId db updateConnSupportPQ :: DB.Connection -> Int64 -> PQSupport -> PQEncryption -> IO () diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 3779006d8d..91ec9a54da 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -32,7 +32,7 @@ import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Protocol (ChatBinding (..), ChatMsgEvent (XInfo), ContactShortLinkData (ContactShortLinkData), LinkOwnerSig, MsgChatLink (..), MsgContent (..), encodeChatBinding) import Simplex.Chat.Store.Groups (getGroupMembers) import Simplex.Chat.Store.Shared (createContact, getGroupInfoKeys) -import Simplex.Chat.Types (AgentConnId (..), ConnStatus (..), GroupId, GroupInfo (..), GroupInfoKeys (..), GroupMember (..), Profile (..), GroupRejectionReason (..), fromLocalProfile, profileFromName, userProfileDirect, pattern VersionChat) +import Simplex.Chat.Types (AgentConnId (..), ChatPeerType (..), ConnStatus (..), GroupId, GroupInfo (..), GroupInfoKeys (..), GroupMember (..), Profile (..), GroupRejectionReason (..), fromLocalProfile, profileFromName, userProfileDirect, pattern VersionChat) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) @@ -133,6 +133,8 @@ chatProfileTests = do it "connection incognito unchanged errors" testConnectionIncognitoUnchangedErrors it "set, reset, set connection incognito" testSetResetSetConnectionIncognito it "join group incognito" testJoinGroupIncognito + it "join group incognito as bot" $ testJoinGroupIncognitoPeerType (Just CPTBot) + it "join group incognito as human" $ testJoinGroupIncognitoPeerType Nothing it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito it "deleting contact first, group second deletes incognito profile" testDeleteContactThenGroupDeletesIncognitoProfile @@ -4905,6 +4907,36 @@ testShortLinkConnectPreparedGroupIncognito = testChat3 aliceProfile bobProfile c alice <# "#team cath> 3" bob ?<# "#team cath> 3" +testJoinGroupIncognitoPeerType :: HasCallStack => Maybe ChatPeerType -> TestParams -> IO () +testJoinGroupIncognitoPeerType peerType_ = testChat3 aliceProfile bobProfile {peerType = peerType_} cathProfile test + where + test alice bob cath = do + createGroup2 "team" alice cath + alice ##> "/create link #team" + (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: ok to connect directly" + groupSLinkData <- getTermLine bob + bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) + bob <## "#team: group is prepared" + bob ##> "/_connect group #1 incognito=on" + bobIncognito <- getTermLine bob + bob <## "#team: connection started incognito" + alice <## (bobIncognito <> ": accepting request to join group #team...") + concurrentlyN_ + [ alice <## ("#team: " <> bobIncognito <> " joined the group"), + do + bob <## "#team: joining the group..." + bob <## ("#team: you joined the group incognito as " <> bobIncognito) + bob <## "#team: member cath (Catherine) is connected", + do + cath <## ("#team: alice added " <> bobIncognito <> " to the group (connecting...)") + cath <## ("#team: new member " <> bobIncognito <> " is connected") + ] + forM_ [alice, cath] $ \cc -> do + GroupMember {memberProfile} <- getTestMember cc "team" (T.pack bobIncognito) + peerType (fromLocalProfile memberProfile) `shouldBe` peerType_ + testShortLinkChangePreparedGroupUser :: HasCallStack => TestParams -> IO () testShortLinkChangePreparedGroupUser = testChat3 aliceProfile bobProfile cathProfile test where From 5a5a927821db2f4547bdbe75d82ee028d0d071c0 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 18 Oct 2026 11:40:08 +0100 Subject: [PATCH 05/10] ci: reduce the number of runners (#7750) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .github/workflows/build.yml | 128 ++++++++++++++++++---------- .github/workflows/packages.yml | 20 +---- scripts/ci/packages_release.py | 12 +-- scripts/ci/test_packages_release.py | 9 +- 4 files changed, 94 insertions(+), 75 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 37395dbaf3..be1784a237 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -176,9 +176,9 @@ jobs: env: image_cache: ghcr.io/simplex-chat/simplex-chat-build-cache:${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }} source_hashes: dist-newstyle/sources.sha256 - concurrency: + concurrency: &linux-concurrency group: ${{ github.workflow }}-${{ github.ref }}-linux-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.ghc }}-${{ needs.maybe-release.outputs.libs_tag }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -205,22 +205,7 @@ jobs: should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237' - - os: 22.04 - os_underscore: 22_04 - arch: aarch64 - runner: "ubuntu-22.04-arm" - should_run: true - ghc: ${{ needs.variables.outputs.GHC_VER }} - hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' - libs: true - - os: 24.04 - os_underscore: 24_04 - arch: aarch64 - runner: "ubuntu-24.04-arm" - should_run: true - ghc: ${{ needs.variables.outputs.GHC_VER }} - hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab' - steps: + steps: &linux-steps - name: Checkout Code if: matrix.should_run == true uses: actions/checkout@v3 @@ -398,7 +383,7 @@ jobs: done strip /out/simplex-chat - # Also for pull requests that change the chat core, for the package tests (packages-pr). + # Also for pull requests that change the chat core, for the package tests. - name: Build library id: build-lib if: matrix.libs && (needs.maybe-release.outputs.libs_tag || (needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64')) @@ -457,14 +442,6 @@ jobs: !dist-lib/src key: ${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}-${{ github.sha }} - - 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 @@ -612,6 +589,50 @@ jobs: exit 1 fi + - name: Setup Node.js + id: setup-node + if: ${{ !cancelled() && needs.maybe-release.outputs.core_changed && steps.build-lib.outcome == 'success' }} + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Setup Python + id: setup-python + if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Build simplex-chat (npm) and add-on + id: build-nodejs + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' }} + shell: bash + 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 --yes node-gyp rebuild + npx tsc && cp src/simplex.* dist + + - name: Test simplex-chat (npm) + id: test-nodejs + if: ${{ !cancelled() && steps.build-nodejs.outcome == 'success' }} + timeout-minutes: 30 + shell: bash + working-directory: packages/simplex-chat-nodejs + env: + SIMPLEX_LIBS_DIR: ${{ github.workspace }}/release-libs/simplex-chat-libs-linux-${{ matrix.arch }}/libs + run: npx jest --forceExit --testNamePattern '^(?!.*use preset servers)' + + - name: Test simplex-chat (PyPI) + if: ${{ !cancelled() && steps.test-nodejs.outcome == 'success' }} + timeout-minutes: 30 + shell: bash + working-directory: packages/simplex-chat-python + run: | + python -m pip install -e ".[test]" + python -m pytest -q + - name: Remove -O2 build directory id: remove-o2 if: env.cache_o0 == 'true' && !cancelled() && steps.build-cli.outcome == 'success' @@ -657,6 +678,40 @@ jobs: !dist-newstyle/src key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + build-linux-push: + name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" + needs: [maybe-release, variables] + if: github.event_name != 'pull_request' + runs-on: ${{ matrix.runner }} + permissions: + contents: write + packages: write + env: + image_cache: ghcr.io/simplex-chat/simplex-chat-build-cache:${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }} + source_hashes: dist-newstyle/sources.sha256 + cache_o0: false + concurrency: *linux-concurrency + strategy: + fail-fast: false + matrix: + include: + - os: 22.04 + os_underscore: 22_04 + arch: aarch64 + runner: "ubuntu-22.04-arm" + should_run: true + ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' + libs: true + - os: 24.04 + os_underscore: 24_04 + arch: aarch64 + runner: "ubuntu-24.04-arm" + should_run: true + ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab' + steps: *linux-steps + # ================================= # Linux PostgreSQL Library Build # ================================= @@ -783,7 +838,7 @@ jobs: runs-on: ${{ matrix.os }} concurrency: group: ${{ github.workflow }}-${{ github.ref }}-macos-${{ matrix.os }}-${{ needs.maybe-release.outputs.libs_tag }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -1082,7 +1137,7 @@ jobs: runs-on: ${{ matrix.os }} concurrency: group: ${{ github.workflow }}-${{ github.ref }}-windows-${{ matrix.os }}-${{ needs.maybe-release.outputs.libs_tag }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -1338,7 +1393,7 @@ jobs: # 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] + needs: [maybe-release, build-linux, build-linux-push, 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: @@ -1348,16 +1403,3 @@ jobs: 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/packages.yml b/.github/workflows/packages.yml index 01c2c1be33..2e58fb4d34 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -39,13 +39,9 @@ on: 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 + # build.yml: 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 @@ -119,12 +115,11 @@ jobs: 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" + --release-missing "$RELEASE_MISSING" --libs-release "$LIBS_RELEASE" >> "$GITHUB_OUTPUT" build: name: "build ${{ matrix.platform }}" @@ -163,13 +158,6 @@ jobs: 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: @@ -177,13 +165,13 @@ jobs: 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) + if: needs.addon.outputs.test && 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) || '' }} + SIMPLEX_LIBS_DIR: ${{ 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)' diff --git a/scripts/ci/packages_release.py b/scripts/ci/packages_release.py index 488d35bb8c..b13b9cc00d 100644 --- a/scripts/ci/packages_release.py +++ b/scripts/ci/packages_release.py @@ -54,8 +54,6 @@ ADDON_RUNNERS = { "windows-x86_64": "windows-latest", } ADDON_PLATFORMS = list(ADDON_RUNNERS) -# build.yml builds libsimplex of pull requests on this platform only -CORE_TEST_PLATFORM = "linux-x86_64" ADDON_ASSETS = [f"simplex-chat-nodejs-{platform}.node" for platform in ADDON_PLATFORMS] SHA256_HEX = re.compile(r"^[0-9a-f]{64}$") RELEASES_FILE = "releases.toml" @@ -275,12 +273,10 @@ class BuildPlan: def build_plan( - event: str, release_missing: bool, libs_artifact: bool, libs_release: str, core_changed: bool, addon_changed: bool + event: str, release_missing: bool, libs_release: str, core_changed: bool, addon_changed: bool ) -> BuildPlan: """Each package test runs once, against the libsimplex of the change: a pull request that changes the core - is tested by build.yml with its own libsimplex (libs_artifact), a new library release with that release.""" - if libs_artifact: - return BuildPlan([CORE_TEST_PLATFORM], test=True) + is tested by build.yml with its own libsimplex, a new library release with that release.""" if libs_release: return BuildPlan(ADDON_PLATFORMS, test=True) if event == "pull_request": @@ -382,11 +378,10 @@ def changes(base: str) -> None: def print_build_plan(args: argparse.Namespace) -> None: - pull_request = args.event == "pull_request" and not args.libs_artifact + pull_request = args.event == "pull_request" result = build_plan( args.event, release_missing=args.release_missing, - libs_artifact=args.libs_artifact, libs_release=args.libs_release, core_changed=pull_request and changed_since(args.base, CORE_SOURCES), addon_changed=pull_request and changed_since(args.base, ADDON_SOURCES), @@ -637,7 +632,6 @@ def main(argv: list[str]) -> None: build_parser.add_argument("--event", required=True, help="github.event_name") build_parser.add_argument("--base", default="", help="the base commit of a pull request") build_parser.add_argument("--release-missing", type=github_bool, default=False) - build_parser.add_argument("--libs-artifact", type=github_bool, default=False) build_parser.add_argument("--libs-release", default="") prepare_parser = commands.add_parser("prepare") for package in ["nodejs", "types", "python"]: diff --git a/scripts/ci/test_packages_release.py b/scripts/ci/test_packages_release.py index 634260cabc..1838525c74 100644 --- a/scripts/ci/test_packages_release.py +++ b/scripts/ci/test_packages_release.py @@ -205,8 +205,8 @@ class PlanTest(unittest.TestCase): class BuildPlanTest(unittest.TestCase): - def plan(self, event="push", release_missing=False, libs_artifact=False, libs_release="", core=False, addon=False): - return build_plan(event, release_missing, libs_artifact, libs_release, core, addon) + def plan(self, event="push", release_missing=False, libs_release="", core=False, addon=False): + return build_plan(event, release_missing, libs_release, core, addon) def test_push_builds_a_missing_addon_release_and_tests_it(self): self.assertEqual(self.plan(release_missing=True), BuildPlan(ADDON_PLATFORMS, test=True)) @@ -220,11 +220,6 @@ class BuildPlanTest(unittest.TestCase): self.assertEqual(self.plan("pull_request", core=True), BuildPlan([], test=False)) self.assertEqual(self.plan("pull_request", core=True, addon=True), BuildPlan(ADDON_PLATFORMS, test=False)) - def test_libsimplex_of_the_pull_request_is_tested_on_its_platform(self): - self.assertEqual( - self.plan("pull_request", libs_artifact=True, core=True), BuildPlan(["linux-x86_64"], test=True) - ) - def test_new_library_release_is_tested_on_every_platform(self): self.assertEqual(self.plan(libs_release="core-1.0.0.2"), BuildPlan(ADDON_PLATFORMS, test=True)) From 056346020487d4c78ac538957c6baef9865c884a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 18 Oct 2026 22:44:33 +0100 Subject: [PATCH 06/10] core, ui: support block markdown (#7734) * plan: support block markdown * update plans * core, ui: markdown plans and implementation * better * simplify * markdown tests and fixes * implement * fixes * fix test * bold * block markdown rendering * fix selection and rtl * fixes --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../Views/Chat/ChatItem/FramedItemView.swift | 7 +- .../Views/Chat/ChatItem/MsgContentView.swift | 461 +++++++++- .../Shared/Views/Chat/ChatItemInfoView.swift | 19 +- apps/ios/Shared/Views/Chat/ChatView.swift | 7 +- .../Chat/ComposeMessage/ContextItemView.swift | 2 +- .../Views/Chat/Group/GroupWelcomeView.swift | 7 +- .../Views/ChatList/ChatPreviewView.swift | 2 +- apps/ios/SimpleXChat/API.swift | 12 +- apps/ios/SimpleXChat/ChatTypes.swift | 119 ++- apps/ios/SimpleXChat/Notifications.swift | 9 +- apps/ios/spec/client/chat-view.md | 2 +- .../chat/simplex/common/model/ChatModel.kt | 98 +- .../simplex/common/platform/NtfManager.kt | 7 +- .../simplex/common/views/chat/ChatInfoView.kt | 6 +- .../common/views/chat/ChatItemInfoView.kt | 12 +- .../common/views/chat/ContextItemView.kt | 2 +- .../common/views/chat/TextSelection.kt | 97 +- .../views/chat/group/WelcomeMessageView.kt | 7 +- .../common/views/chat/item/FramedItemView.kt | 13 +- .../common/views/chat/item/MarkdownBlocks.kt | 248 +++++ .../common/views/chat/item/TextItemView.kt | 183 +++- .../common/views/chatlist/ChatPreviewView.kt | 1 + .../common/views/helpers/TextEditor.kt | 9 +- .../images/vertical_logo@1x.png | Bin 0 -> 1234 bytes .../images/vertical_logo@2x.png | Bin 0 -> 1617 bytes .../images/vertical_logo@3x.png | Bin 0 -> 2010 bytes .../chat/simplex/app/MarkdownBlocksTest.kt | 133 +++ apps/multiplatform/spec/client/chat-view.md | 2 +- .../src/Directory/Listing.hs | 15 +- .../src/Directory/Service.hs | 8 +- bots/api/TYPES.md | 144 +++ bots/src/API/Docs/Types.hs | 12 +- .../types/typescript/src/types.ts | 197 ++++ .../src/simplex_chat/types/_types.py | 142 ++- plans/2026-10-13-block-markdown-rendering.md | 415 +++++++++ plans/2026-10-13-block-markdown.md | 867 ++++++++++++++++++ src/Simplex/Chat/Help.hs | 4 +- src/Simplex/Chat/Library/Commands.hs | 14 +- src/Simplex/Chat/Library/Internal.hs | 44 +- src/Simplex/Chat/Library/Subscriber.hs | 5 +- src/Simplex/Chat/Markdown.hs | 598 ++++++++++-- src/Simplex/Chat/Messages.hs | 13 +- src/Simplex/Chat/Mobile.hs | 4 +- src/Simplex/Chat/Store/Messages.hs | 30 +- src/Simplex/Chat/Styled.hs | 2 +- src/Simplex/Chat/View.hs | 2 +- src/Simplex/Chat/Web.hs | 24 +- tests/ChatTests/Groups.hs | 6 + tests/JSONFixtures.hs | 10 +- tests/JSONTests.hs | 26 +- tests/MarkdownTests.hs | 616 ++++++++++++- website/src/directory.html | 51 ++ website/src/js/channel-preview.jsc | 70 +- website/src/js/directory.jsc | 9 +- website/src/js/simplex-lib.jsc | 159 +++- 55 files changed, 4641 insertions(+), 311 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt create mode 100644 apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png create mode 100644 apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png create mode 100644 apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@3x.png create mode 100644 apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt create mode 100644 plans/2026-10-13-block-markdown-rendering.md create mode 100644 plans/2026-10-13-block-markdown.md diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index f822970ea4..b6bac78d46 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -334,7 +334,7 @@ struct FramedItemView: View { @inline(__always) private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View { - MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline, prefix: prefix, stripLink: stripLink) + MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, markdownBlocks: qi.markdownBlocks, textStyle: .subheadline, prefix: prefix, stripLink: stripLink) .lineLimit(lines) .padding(.bottom, 6) } @@ -364,13 +364,16 @@ struct FramedItemView: View { chat: chat, text: text, formattedText: ft, + markdownBlocks: text == "" ? nil : ci.markdownBlocks, textStyle: .body, meta: ci.meta, mentions: ci.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, rightToLeft: rtl, prefix: txtPrefix, - stripLink: stripLink + stripLink: stripLink, + blockLayout: true, + cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote ) .environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme))) .multilineTextAlignment(rtl ? .trailing : .leading) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index f95e31369e..10bd784b7e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -32,6 +32,7 @@ struct MsgContentView: View { @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? = nil + var markdownBlocks: [MarkdownBlock]? = nil var textStyle: UIFont.TextStyle var sender: String? = nil var meta: CIMeta? = nil @@ -40,6 +41,8 @@ struct MsgContentView: View { var rightToLeft = false var prefix: NSAttributedString? = nil var stripLink: String? = nil + var blockLayout = false + var cardColor: Color? = nil @State private var showSecrets: Set = [] @State private var typingIdx = 0 @State private var timer: Timer? @@ -51,7 +54,7 @@ struct MsgContentView: View { @AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true var body: some View { - let v = msgContentView() + let v = contentView() if meta?.isLive == true { v.onAppear { let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) @@ -95,11 +98,67 @@ struct MsgContentView: View { typingIdx = 0 } + @ViewBuilder + private func contentView() -> some View { + let fts = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText } + if blockLayout, let fts, !fts.isEmpty, let blocks = markdownBlocks { + MarkdownBlocksView( + formattedText: fts, + blocks: blocks, + textStyle: textStyle, + cardColor: cardColor ?? theme.appColors.receivedQuote, + rightToLeft: rightToLeft, + blockText: { index, range, font, secretStart, last in blockTextView(fts, blocks, index, range, font, secretStart, last) }, + metaLine: { metaLineView() } + ) + } else { + msgContentView() + } + } + + private func blockTextView(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ index: Int, _ range: Range, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> some View { + let r = messageText( + text, + fts, + markdownBlocks: blocks, + textStyle: textStyle, + sender: index == 0 ? sender : nil, + mentions: mentions, + userMemberId: userMemberId, + showSecrets: showSecrets, + commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady, + backgroundColor: containerBackground, + prefix: index == 0 ? prefix : nil, + range: range, + baseFont: font, + secretStart: secretStart + ) + let s = r.string + let t: Text + if last, let mt = meta { + if mt.isLive { + s.append(typingIndicator(mt.recent)) + } + t = Text(AttributedString(s)) + reserveSpaceForMeta(mt) + } else { + t = Text(AttributedString(s)) + } + return msgTextResultView(r, t, showSecrets: $showSecrets, sendCommand: { cmd in sendCommandMsg(chat, cmd) }) + } + + @ViewBuilder + private func metaLineView() -> some View { + if let mt = meta { + (mt.isLive ? Text(AttributedString(typingIndicator(mt.recent))) : Text(verbatim: "")) + reserveSpaceForMeta(mt, newLine: false) + } + } + @inline(__always) private func msgContentView() -> some View { let r = messageText( text, formattedText, + markdownBlocks: markdownBlocks, textStyle: textStyle, sender: sender, mentions: mentions, @@ -131,8 +190,8 @@ struct MsgContentView: View { } @inline(__always) - private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature) + private func reserveSpaceForMeta(_ mt: CIMeta, newLine: Bool = true) -> Text { + (rightToLeft && newLine ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature) } } @@ -317,8 +376,11 @@ private struct FullProfileDescriptionView: View { .listRowBackground(Color.clear) Section { - let r = markdownText(description, showSecrets: showSecrets, backgroundColor: theme.colors.background) - msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + let parsed = parseSimpleXMarkdownText(description) + MarkdownView( + text: description, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks, + backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets + ) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } @@ -338,9 +400,11 @@ func markdownText( showSecrets: Set? = nil, backgroundColor: Color ) -> MsgTextResult { - messageText( + let parsed = parseSimpleXMarkdownText(s) + return messageText( s, - parseSimpleXMarkdown(s), + parsed?.formattedText, + markdownBlocks: parsed?.markdownBlocks, textStyle: textStyle, sender: sender, preview: preview, @@ -396,6 +460,7 @@ func markdownProfileDescription( func messageText( _ text: String, _ formattedText: [FormattedText]?, + markdownBlocks: [MarkdownBlock]? = nil, textStyle: UIFont.TextStyle = .body, sender: String?, preview: Bool = false, @@ -405,21 +470,24 @@ func messageText( commands: Bool = false, backgroundColor: UIColor, prefix: NSAttributedString? = nil, - stripLink: String? = nil + stripLink: String? = nil, + range: Range? = nil, + baseFont: UIFont? = nil, + secretStart: Int = 0 ) -> MsgTextResult { let text = if let stripLink { stripTextLink(text, stripLink) } else { text } let formattedText = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText } let res = NSMutableAttributedString() - let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) - let font = UIFont.preferredFont(forTextStyle: textStyle) + let descr = baseFont?.fontDescriptor ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) + let font = baseFont ?? UIFont.preferredFont(forTextStyle: textStyle) let plain: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: UIColor.label ] let secretColor = backgroundColor.withAlphaComponent(1) - var link: [NSAttributedString.Key: Any]? var hasSecrets = false var handleTaps = false + var secretIdx: Int = secretStart if let sender { if preview { @@ -437,19 +505,57 @@ func messageText( } if let fts = formattedText, fts.count > 0 { + if let range { + let start = min(max(range.lowerBound, 0), fts.count) + appendSegments(fts[start.. (descr: UIFontDescriptor, font: UIFont) { + if !heading { return (descr, font) } + let d = descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]) + return (d, UIFont(descriptor: d, size: d.pointSize)) + } + + func appendSegments(_ fts: ArraySlice, _ heading: Bool) { + let (descr, font) = segmentFonts(heading) + let plain: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor.label + ] + var link: [NSAttributedString.Key: Any]? var bold: UIFont? var italic: UIFont? var snippet: UIFont? var small: UIFont? var mention: UIFont? - var secretIdx: Int = 0 for ft in fts { var t = ft.text var attrs = plain switch (ft.format) { - case .bold: - bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize) - attrs[.font] = bold + case let .bold(singleStar): + if singleStar && markdownBlocks != nil { + italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) + attrs[.font] = italic + } else { + bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize) + attrs[.font] = bold + } case .italic: italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) attrs[.font] = italic @@ -496,7 +602,7 @@ func messageText( if let text { t = text } if !preview { attrs[linkAttrKey] = uri - attrs[webLinkAttrKey] = true + if !ft.isEmailLink { attrs[webLinkAttrKey] = true } handleTaps = true } case let .simplexLink(text, linkType, simplexUri, smpHosts): @@ -563,24 +669,319 @@ func messageText( attrs[modalAttrKey] = Format.modal(modalName: modalName, text: text) handleTaps = true } + case .escape: t = "" + case let .markup(token): + if range != nil { t = token.drawnText } else if markdownBlocks != nil { t = token.previewText } case .unknown: () case .none: () } res.append(NSAttributedString(string: t, attributes: attrs)) } - } else { - res.append(NSMutableAttributedString(string: text, attributes: plain)) - } - - return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps) - func linkAttrs() -> [NSAttributedString.Key: Any] { - link = link ?? [ - .font: font, - .foregroundColor: uiLinkColor, - .underlineStyle: NSUnderlineStyle.single.rawValue - ] - return link! + func linkAttrs() -> [NSAttributedString.Key: Any] { + link = link ?? [ + .font: font, + .foregroundColor: uiLinkColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + return link! + } + } +} + +let swipeExclusionSpace = "swipeExclusion" + +private let maxGridCells = 1000 + +struct SwipeExclusionFrames: PreferenceKey { + static var defaultValue: [CGRect] = [] + + static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { + value += nextValue() + } +} + +struct MarkdownView: View { + var text: String + var formattedText: [FormattedText]? + var markdownBlocks: [MarkdownBlock]? + var sender: String? = nil + var mentions: [String: CIMention]? = nil + var userMemberId: String? = nil + var backgroundColor: Color + var cardColor: Color + @Binding var showSecrets: Set + + var body: some View { + if let fts = formattedText, !fts.isEmpty, let blocks = markdownBlocks { + MarkdownBlocksView( + formattedText: fts, + blocks: blocks, + textStyle: .body, + cardColor: cardColor, + rightToLeft: isRightToLeft(text), + blockText: { index, range, font, secretStart, _ in + let r = messageText( + text, fts, markdownBlocks: blocks, sender: index == 0 ? sender : nil, mentions: mentions, userMemberId: userMemberId, + showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor), range: range, baseFont: font, secretStart: secretStart + ) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + }, + metaLine: { EmptyView() } + ) + } else { + let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor)) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + } + } +} + +private struct BlocksLayout { + let font: UIFont + let starts: [Int] + let secrets: [Int] + let lastIsText: Bool + + init(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ textStyle: UIFont.TextStyle) { + font = UIFont.preferredFont(forTextStyle: textStyle) + starts = blocks.reduce(into: [0]) { $0.append($0[$0.count - 1] + $1.textRanges.count) } + secrets = fts.reduce(into: [0]) { $0.append($0[$0.count - 1] + ($1.isSecret ? 1 : 0)) } + lastIsText = switch blocks.last { + case .paragraph, .heading, .quote, .list: true + default: false + } + } +} + +struct MarkdownBlocksView: View { + @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness + var formattedText: [FormattedText] + var blocks: [MarkdownBlock] + var textStyle: UIFont.TextStyle + var cardColor: Color + var rightToLeft: Bool + @ViewBuilder var blockText: (_ index: Int, _ range: Range, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> BlockText + @ViewBuilder var metaLine: () -> MetaLine + + var body: some View { + let l = BlocksLayout(formattedText, blocks, textStyle) + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(blocks.enumerated()), id: \.offset) { k, block in + if k > 0 && paragraphBreak(blocks[k - 1].endIndex, block.startIndex) { + Color.clear.frame(height: l.font.lineHeight / 2) + } + blockView(block, l.starts[k], l) + } + if !l.lastIsText { + metaLine() + } + } + .multilineTextAlignment(.leading) + .environment(\.layoutDirection, rightToLeft ? .rightToLeft : .leftToRight) + } + + private func text(_ index: Int, _ r: SegmentRange, _ font: UIFont, _ l: BlocksLayout) -> BlockText { + blockText(index, r.from.. some View { + switch block { + case let .paragraph(content): + text(start, content, l.font, l) + case let .heading(level, content): + text(start, content, headingFont(l.font, level), l) + case let .quote(content): + HStack(alignment: .top, spacing: 10) { + RoundedRectangle(cornerRadius: 2).fill(Color(uiColor: .label).opacity(0.16)).frame(width: 4) + text(start, content, l.font, l).opacity(0.72) + } + .fixedSize(horizontal: false, vertical: true) + case let .list(items): + ForEach(Array(listEntries(items, 0).enumerated()), id: \.offset) { j, e in + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(e.entry.number ?? "•").font(.system(size: l.font.pointSize)) + text(start + j, e.entry.content, l.font, l) + } + .padding(.leading, 20 * CGFloat(e.level)) + } + case let .codeBlock(_, content): + CodeBlockCard(color: cardColor, radius: 12 * roundness, copyText: codeText(content)) { + text(start, content, monospacedFont(l.font), l).fixedSize(horizontal: true, vertical: false) + } + case let .table(columns, header, rows): + tableView(columns, [header] + rows, start, l) + case .rule: + Rectangle().fill(Color(uiColor: .secondaryLabel).opacity(0.35)).frame(height: 1).padding(.vertical, 6) + } + } + + @ViewBuilder private func tableView(_ columns: [TableAlign], _ tableRows: [[SegmentRange]], _ start: Int, _ l: BlocksLayout) -> some View { + let rowStarts = tableRows.reduce(into: [start]) { $0.append($0[$0.count - 1] + $1.count) } + let columnCount = max(columns.count, tableRows.map(\.count).max() ?? 0) + let lineColor = Color(uiColor: .secondaryLabel).opacity(0.35) + let radius = 12 * roundness + if #available(iOS 16, *) { + if columnCount * tableRows.count <= maxGridCells { + HScroll { + Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { + ForEach(Array(tableRows.enumerated()), id: \.offset) { r, row in + GridRow { + ForEach(0.. some View { + let source = tableSource(tableRows) + return CodeBlockCard(color: cardColor, radius: radius, copyText: source) { + Text(verbatim: source).font(.system(size: l.font.pointSize * 0.875, design: .monospaced)).fixedSize(horizontal: true, vertical: false) + } + } + + private func paragraphBreak(_ from: Int, _ to: Int) -> Bool { + let start = min(max(from, 0), formattedText.count) + return formattedText[start.. String { + let start = min(max(r.from, 0), formattedText.count) + return formattedText[start.. String { + let start = min(max((tableRows.first?.first?.from ?? 1) - 1, 0), formattedText.count) + let end = min((tableRows.last?.last?.to ?? 0) + 1, formattedText.count) + return formattedText[start.. UIFont { + let scale: CGFloat = switch level { + case 1: 1.5 + case 2: 1.3 + case 3: 1.15 + default: 1 + } + return UIFont(descriptor: boldFont(font).fontDescriptor, size: font.pointSize * scale) +} + +private func boldFont(_ font: UIFont) -> UIFont { + UIFont(descriptor: font.fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: font.pointSize) +} + +private func monospacedFont(_ font: UIFont) -> UIFont { + UIFont.monospacedSystemFont(ofSize: font.pointSize * 0.875, weight: .regular) +} + +private func cellAlignment(_ align: TableAlign) -> Alignment { + switch align { + case .right: .trailing + case .center: .center + default: .leading + } +} + +private struct CodeBlockCard: View { + @EnvironmentObject var theme: AppTheme + var color: Color + var radius: CGFloat + var copyText: String + @ViewBuilder var content: () -> Content + @State private var showCopy = false + + var body: some View { + HScroll { + content() + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(alignment: .topTrailing) { + if showCopy { + Button { + UIPasteboard.general.string = copyText + showCopy = false + } label: { + Image(systemName: "doc.on.doc") + .foregroundColor(theme.colors.primary) + .frame(width: 28, height: 28) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: radius * 2 / 3)) + } + .padding(4) + } + } + .background(color) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .onTapGesture { showCopy.toggle() } + } +} + +private struct HScroll: View { + @ViewBuilder var content: () -> Content + + var body: some View { + if #available(iOS 16, *) { + ViewThatFits(in: .horizontal) { + content() + scrollView() + } + } else { + scrollView() + } + } + + private func scrollView() -> some View { + ScrollView(.horizontal, showsIndicators: false) { + content() + } + .background { + GeometryReader { g in + Color.clear.preference(key: SwipeExclusionFrames.self, value: [g.frame(in: .named(swipeExclusionSpace))]) + } + } } } @@ -608,7 +1009,9 @@ func stripTextLink(_ text: String, _ link: String) -> String { func stripFormattedTextLink(_ ft: [FormattedText]?, _ link: String) -> [FormattedText]? { guard var ft, ft.last?.text == link else { return ft } ft.removeLast() - if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") { + if let i = ft.indices.last, case .markup = ft[i].format { + ft.removeLast() + } else if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") { ft[i].text = String(ft[i].text.dropLast()) if ft[i].text.isEmpty { ft.removeLast() } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index ffcf525300..3fff0e34d1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -267,7 +267,7 @@ struct ChatItemInfoView: View { private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { let backgroundColor = chatItemFrameColor(ci, theme) return VStack(alignment: .leading, spacing: 4) { - textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: backgroundColor) + textBubble(itemVersion.msgContent.text, itemVersion.formattedText, itemVersion.markdownBlocks, nil, backgroundColor: backgroundColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(backgroundColor) @@ -295,9 +295,12 @@ struct ChatItemInfoView: View { .frame(maxWidth: maxWidth, alignment: .leading) } - @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: Color) -> some View { + @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ markdownBlocks: [MarkdownBlock]?, _ sender: String? = nil, backgroundColor: Color) -> some View { if text != "" { - TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor) + TextBubble( + text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, + backgroundColor: backgroundColor, cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote + ) } else { Text("no text") .italic() @@ -309,15 +312,19 @@ struct ChatItemInfoView: View { @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? + var markdownBlocks: [MarkdownBlock]? var sender: String? = nil var mentions: [String: CIMention]? var userMemberId: String? var backgroundColor: Color + var cardColor: Color @State private var showSecrets: Set = [] var body: some View { - let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor)) - return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + MarkdownView( + text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: mentions, userMemberId: userMemberId, + backgroundColor: backgroundColor, cardColor: cardColor, showSecrets: $showSecrets + ) } } @@ -342,7 +349,7 @@ struct ChatItemInfoView: View { private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { let backgroundColor = quotedMsgFrameColor(qi, theme) return VStack(alignment: .leading, spacing: 4) { - textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: backgroundColor) + textBubble(qi.text, qi.formattedText, qi.markdownBlocks, qi.getSender(nil), backgroundColor: backgroundColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(quotedMsgFrameColor(qi, theme)) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 29a1be3d65..2a903fcd91 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1735,6 +1735,7 @@ struct ChatView: View { @State private var markReadTask: Task? = nil @State private var actionSheet: SomeActionSheet? = nil @State private var swipeOffset: CGFloat = 0 + @State private var swipeExclusionFrames: [CGRect] = [] var revealed: Bool { revealedItems.contains(chatItem.id) } @@ -2156,12 +2157,14 @@ struct ChatView: View { .padding(.bottom, 4) } } + .coordinateSpace(name: swipeExclusionSpace) + .onPreferenceChange(SwipeExclusionFrames.self) { swipeExclusionFrames = $0 } .offset(x: swipeOffset) .contentShape(Rectangle()) .simultaneousGesture( - DragGesture(minimumDistance: 10) + DragGesture(minimumDistance: 10, coordinateSpace: .named(swipeExclusionSpace)) .onChanged { value in - guard canReply else { return } + guard canReply, !swipeExclusionFrames.contains(where: { $0.contains(value.startLocation) }) else { return } let x = value.translation.width if x < 0 { swipeOffset = max(x * 0.63, -56) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 1f328b2061..13614692cb 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -71,7 +71,7 @@ struct ContextItemView: View { } private func contextMsgPreview(_ contextItem: ChatItem) -> some View { - let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr) + let r = messageText(contextItem.text, contextItem.formattedText, markdownBlocks: contextItem.markdownBlocks, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr) let t = attachment() + Text(AttributedString(r.string)) return t.if(r.hasSecrets, transform: hiddenSecretsView) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index f58f2c213d..1bef869a66 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -59,8 +59,11 @@ struct GroupWelcomeView: View { } private func textPreview() -> some View { - let r = markdownText(welcomeText, showSecrets: showSecrets, backgroundColor: theme.colors.background) - return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + let parsed = parseSimpleXMarkdownText(welcomeText) + return MarkdownView( + text: welcomeText, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks, + backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets + ) .frame(minHeight: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 8156ea81c7..7f50f09a31 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -299,7 +299,7 @@ struct ChatPreviewView: View { func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) { let (itemText, itemFormattedText) = chatItemPreviewText(cItem) - let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) + let r = messageText(itemText, itemFormattedText, markdownBlocks: itemFormattedText == nil ? nil : cItem.markdownBlocks, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) return (Text(AttributedString(r.string)), r.hasSecrets) func chatItemPreviewText(_ ci: ChatItem) -> (String, [FormattedText]?) { diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 3b860d524b..04e5973085 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -149,12 +149,15 @@ public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTi } public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? { + parseSimpleXMarkdownText(s)?.formattedText +} + +public func parseSimpleXMarkdownText(_ s: String) -> ParsedMarkdown? { var c = s.cString(using: .utf8)! if let cjson = chat_parse_markdown(&c) { if let d = dataFromCString(cjson) { do { - let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d) - return r.formattedText + return try jsonDecoder.decode(ParsedMarkdown.self, from: d) } catch { logger.error("parseSimpleXMarkdown jsonDecoder.decode error: \(error.localizedDescription)") } @@ -176,8 +179,9 @@ public func parseBadgeCode(_ s: String) -> String? { return canonical.isEmpty ? nil : canonical } -struct ParsedMarkdown: Decodable { - var formattedText: [FormattedText]? +public struct ParsedMarkdown: Decodable { + public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? } public func parseServerAddress(_ s: String) -> ServerAddress? { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a4d65a6d10..8bdd952372 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3793,11 +3793,12 @@ public struct CIReaction: Decodable, Hashable { } public struct ChatItem: Identifiable, Decodable, Hashable { - public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { + public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, markdownBlocks: [MarkdownBlock]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { self.chatDir = chatDir self.meta = meta self.content = content self.formattedText = formattedText + self.markdownBlocks = markdownBlocks self.mentions = mentions self.quotedItem = quotedItem self.reactions = reactions @@ -3808,6 +3809,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var meta: CIMeta public var content: CIContent public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? public var mentions: [String: CIMention]? public var quotedItem: CIQuote? public var reactions: [CIReactionCount] @@ -3817,7 +3819,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var isLiveDummy: Bool = false private enum CodingKeys: String, CodingKey { - case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file + case chatDir, meta, content, formattedText, markdownBlocks, mentions, quotedItem, reactions, file } public var id: Int64 { meta.itemId } @@ -4859,6 +4861,7 @@ public struct CIQuote: Decodable, ItemContent, Hashable { public var sentAt: Date public var content: MsgContent public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? public var text: String { switch (content.text, content) { case let ("", .voice(_, duration)): return durationText(duration) @@ -5672,14 +5675,119 @@ public struct FormattedText: Decodable, Hashable { public var linkUri: String? { switch format { case .uri: text - case let .hyperLink(_, linkUri): linkUri + case let .hyperLink(_, linkUri): isEmailLink ? nil : linkUri default: nil } } + + public var isEmailLink: Bool { + if case let .hyperLink(_, linkUri) = format { linkUri.hasPrefix("mailto:") } else { false } + } +} + +public enum BlockToken: Decodable, Hashable { + case lineBreak + case softBreak + case hardBreak + case paragraphBreak(lineBreaks: Int) + case indent + case headingMark(level: Int) + case headingClose + case headingUnderline(level: Int) + case listMark(number: String?) + case quoteMark + case fenceMark(language: String?) + case rowMark + case cellMark + case delimiterRow(columns: [TableAlign]) + case ruleMark + + public var drawnText: String { + switch self { + case .softBreak: " " + case .lineBreak, .hardBreak: "\n" + case let .paragraphBreak(lineBreaks): String(repeating: "\n", count: lineBreaks) + default: "" + } + } + + public var previewText: String { + switch self { + case .softBreak, .hardBreak, .cellMark: " " + case .lineBreak, .paragraphBreak: "\n" + case let .listMark(number): (number ?? "•") + " " + default: "" + } + } +} + +public enum MarkdownBlock: Decodable, Hashable { + case paragraph(content: SegmentRange) + case heading(level: Int, content: SegmentRange) + case codeBlock(language: String?, content: SegmentRange) + case quote(content: SegmentRange) + case list(items: [ListEntry]) + case table(columns: [TableAlign], header: [SegmentRange], rows: [[SegmentRange]]) + case rule(mark: SegmentRange) +} + +public struct ListEntry: Decodable, Hashable { + public var number: String? + public var content: SegmentRange + public var items: [ListEntry] +} + +public struct SegmentRange: Decodable, Hashable { + public var from: Int + public var to: Int +} + +public enum TableAlign: String, Decodable, Hashable { + case none + case left + case center + case right +} + +public struct BlockTextRange { + public var range: SegmentRange + public var inRow: Bool +} + +extension MarkdownBlock { + public var textRanges: [BlockTextRange] { + switch self { + case let .paragraph(content), let .heading(_, content), let .codeBlock(_, content), let .quote(content): + [BlockTextRange(range: content, inRow: false)] + case let .list(items): + listEntries(items, 0).map { BlockTextRange(range: $0.entry.content, inRow: false) } + case let .table(_, header, rows): + ([header] + rows).flatMap { row in row.enumerated().map { c, cell in BlockTextRange(range: cell, inRow: c > 0) } } + case .rule: + [] + } + } +} + +public func listEntries(_ items: [ListEntry], _ level: Int) -> [(entry: ListEntry, level: Int)] { + items.flatMap { [(entry: $0, level: level)] + listEntries($0.items, level + 1) } +} + +public func blockTextRanges(_ blocks: [MarkdownBlock]) -> [BlockTextRange] { + blocks.flatMap { $0.textRanges } +} + +public func headingSegments(_ size: Int, _ blocks: [MarkdownBlock]) -> [Bool] { + var bold = [Bool](repeating: false, count: size) + for case let .heading(_, content) in blocks { + let from = min(max(content.from, 0), size) + for i in from.. String { if let md = cItem.formattedText { var res = "" for ft in md { - if case .secret = ft.format { - res = res + "..." - } else { - res = res + ft.text + switch ft.format { + case .secret: res = res + "..." + case let .markup(token): res = res + token.previewText + case .escape: () + default: res = res + ft.text } } return res diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index b63d336c34..38db89bea0 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -189,7 +189,7 @@ Renders emoji-only messages (messages containing only emoji characters) in a lar **File**: [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) -Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). +Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). Blocks of `markdownBlocks` (headings, lists, quotes, code blocks, tables, rules) are drawn as a column of views by `MarkdownBlocksView`; code blocks and tables scroll sideways. ### DeletedItemView / MarkedDeletedItemView 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 bf856c13af..09c494dc76 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 @@ -3407,6 +3407,7 @@ data class ChatItem ( val meta: CIMeta, val content: CIContent, val formattedText: List? = null, + val markdownBlocks: List? = null, val mentions: Map? = null, val quotedItem: CIQuote? = null, val reactions: List, @@ -4412,7 +4413,8 @@ class CIQuote ( val sharedMsgId: String? = null, val sentAt: Instant, val content: MsgContent, - val formattedText: List? = null + val formattedText: List? = null, + val markdownBlocks: List? = null ): ItemContent { override val text: String by lazy { if (content.text == "" && content is MsgContent.MCVoice) @@ -5164,7 +5166,7 @@ class FormattedText(val text: String, val format: Format? = null) { val linkUri: String? get() = when (format) { is Format.Uri -> text - is Format.HyperLink -> format.linkUri + is Format.HyperLink -> if (format.isEmail) null else format.linkUri else -> null } @@ -5175,7 +5177,7 @@ class FormattedText(val text: String, val format: Format? = null) { @Serializable sealed class Format { - @Serializable @SerialName("bold") class Bold: Format() + @Serializable @SerialName("bold") class Bold(val singleStar: Boolean = false): Format() @Serializable @SerialName("italic") class Italic: Format() @Serializable @SerialName("strikeThrough") class StrikeThrough: Format() @Serializable @SerialName("snippet") class Snippet: Format() @@ -5183,7 +5185,9 @@ sealed class Format { @Serializable @SerialName("small") class Small: Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() - @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() + @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() { + val isEmail: Boolean get() = linkUri.startsWith("mailto:") + } @Serializable @SerialName("simplexLink") class SimplexLink(val showText: String?, val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() { val simplexLinkText: String get() = "${linkType.description} $viaHosts" @@ -5200,6 +5204,8 @@ sealed class Format { } @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() + @Serializable @SerialName("escape") class Escape: Format() + @Serializable @SerialName("markup") class Markup(val token: BlockToken): Format() @Serializable @SerialName("unknown") class Unknown: Format() val style: SpanStyle @Composable get() = when (this) { @@ -5219,6 +5225,8 @@ sealed class Format { is Email -> linkStyle is Phone -> linkStyle is Modal -> linkStyle + is Escape -> SpanStyle() + is Markup -> SpanStyle() is Unknown -> SpanStyle() } @@ -5229,6 +5237,87 @@ sealed class Format { } } +@Serializable +sealed class BlockToken { + @Serializable @SerialName("lineBreak") class LineBreak: BlockToken() + @Serializable @SerialName("softBreak") class SoftBreak: BlockToken() + @Serializable @SerialName("hardBreak") class HardBreak: BlockToken() + @Serializable @SerialName("paragraphBreak") class ParagraphBreak(val lineBreaks: Int): BlockToken() + @Serializable @SerialName("indent") class Indent: BlockToken() + @Serializable @SerialName("headingMark") class HeadingMark(val level: Int): BlockToken() + @Serializable @SerialName("headingClose") class HeadingClose: BlockToken() + @Serializable @SerialName("headingUnderline") class HeadingUnderline(val level: Int): BlockToken() + @Serializable @SerialName("listMark") class ListMark(val number: String? = null): BlockToken() + @Serializable @SerialName("quoteMark") class QuoteMark: BlockToken() + @Serializable @SerialName("fenceMark") class FenceMark(val language: String? = null): BlockToken() + @Serializable @SerialName("rowMark") class RowMark: BlockToken() + @Serializable @SerialName("cellMark") class CellMark: BlockToken() + @Serializable @SerialName("delimiterRow") class DelimiterRow(val columns: List): BlockToken() + @Serializable @SerialName("ruleMark") class RuleMark: BlockToken() + + val drawnText: String get() = when (this) { + is SoftBreak -> " " + is LineBreak, is HardBreak -> "\n" + is ParagraphBreak -> "\n".repeat(lineBreaks) + else -> "" + } + + val previewText: String get() = when (this) { + is SoftBreak, is HardBreak, is CellMark -> " " + is LineBreak, is ParagraphBreak -> "\n" + is ListMark -> (number ?: "•") + " " + else -> "" + } +} + +@Serializable +sealed class MarkdownBlock { + @Serializable @SerialName("paragraph") class Paragraph(val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("heading") class Heading(val level: Int, val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("codeBlock") class CodeBlock(val language: String? = null, val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("quote") class Quote(val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("list") class ItemList(val items: List): MarkdownBlock() + @Serializable @SerialName("table") class Table(val columns: List, val header: List, val rows: List>): MarkdownBlock() + @Serializable @SerialName("rule") class Rule(val mark: SegmentRange): MarkdownBlock() +} + +@Serializable +class ListEntry(val number: String? = null, val content: SegmentRange, val items: List) + +@Serializable +class SegmentRange(val from: Int, val to: Int) + +@Serializable +enum class TableAlign { + @SerialName("none") None, + @SerialName("left") Left, + @SerialName("center") Center, + @SerialName("right") Right +} + +class BlockTextRange(val range: SegmentRange, val inRow: Boolean) + +val MarkdownBlock.textRanges: List get() = when (this) { + is MarkdownBlock.Paragraph -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.Heading -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.CodeBlock -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.Quote -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.ItemList -> listEntries(items, 0).map { (entry, _) -> BlockTextRange(entry.content, false) } + is MarkdownBlock.Table -> (listOf(header) + rows).flatMap { row -> row.mapIndexed { c, cell -> BlockTextRange(cell, c > 0) } } + is MarkdownBlock.Rule -> emptyList() +} + +fun listEntries(items: List, level: Int): List> = + items.flatMap { listOf(it to level) + listEntries(it.items, level + 1) } + +fun blockTextRanges(blocks: List): List = blocks.flatMap { it.textRanges } + +fun headingSegments(size: Int, blocks: List): BooleanArray { + val bold = BooleanArray(size) + blocks.forEach { if (it is MarkdownBlock.Heading) for (i in it.content.from.coerceIn(0, size) until it.content.to.coerceIn(0, size)) bold[i] = true } + return bold +} + @Serializable enum class SimplexLinkType(val linkType: String) { contact("contact"), @@ -5714,6 +5803,7 @@ data class ChatItemVersion( val chatItemVersionId: Long, val msgContent: MsgContent, val formattedText: List?, + val markdownBlocks: List? = null, val itemVersionTs: Instant, val createdAt: Instant, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index ee47403cd3..290d93db71 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -136,7 +136,12 @@ abstract class NtfManager { return if (md != null) { var res = "" for (ft in md) { - res += if (ft.format is Format.Secret) "..." else ft.text + res += when (ft.format) { + is Format.Secret -> "..." + is Format.Markup -> ft.format.token.previewText + is Format.Escape -> "" + else -> ft.text + } } res } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 9c019ad535..82575984f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -791,8 +791,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex val linkMode = chatModel.simplexLinkMode.value if (descr == null) { if (short != null) { + val parsed = parseToMarkdownText(short) MarkdownText( - short, parseToMarkdown(short), toggleSecrets = true, style = style, maxLines = 4, + short, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4, overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode ) } @@ -801,8 +802,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex val truncated = firstLine.length > 100 val multiline = descr.length > firstLine.length if (short == null && !truncated && !multiline) { + val parsed = parseToMarkdownText(descr) MarkdownText( - descr, parseToMarkdown(descr), toggleSecrets = true, style = style, maxLines = 4, + descr, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4, overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode ) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index e4f93a0b8a..0c7f1937f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -52,17 +52,19 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools val selection = remember { mutableStateOf(CIInfoTab.History) } @Composable - fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { + fun TextBubble(text: String, formattedText: List?, markdownBlocks: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { if (text != "") { MarkdownText( - text, if (text.isEmpty()) emptyList() else formattedText, + text, if (text.isEmpty()) emptyList() else formattedText, markdownBlocks, sender = sender, mentions = mentions, userMemberId = userMemberId, senderBold = true, toggleSecrets = true, linkMode = SimplexLinkMode.DESCRIPTION, uriHandler = uriHandler, - onLinkLongClick = { showMenu.value = true } + onLinkLongClick = { showMenu.value = true }, + blockLayout = true, + cardColor = if (sent) appColors.sentQuote else appColors.receivedQuote ) } else { Text( @@ -85,7 +87,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, ciVersion.formattedText, sender = null, showMenu = showMenu, mentions = ci.mentions, + TextBubble(text, ciVersion.formattedText, ciVersion.markdownBlocks, sender = null, showMenu = showMenu, mentions = ci.mentions, userMemberId = when { chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null @@ -137,7 +139,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, qi.formattedText, sender = qi.sender(null), showMenu) + TextBubble(text, qi.formattedText, qi.markdownBlocks, sender = qi.sender(null), showMenu) } } Row(Modifier.padding(start = 12.dp, top = 3.dp, bottom = 16.dp)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index b477f56118..7f92ca0651 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -57,7 +57,7 @@ fun ContextItemView( } } else null MarkdownText( - contextItem.text, contextItem.formattedText, + contextItem.text, contextItem.formattedText, contextItem.markdownBlocks, sender = null, toggleSecrets = false, maxLines = lines, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt index 20f5acd3e0..e04f75d799 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.chat.item.itemPrefixText -import chat.simplex.common.views.chat.item.itemSegmentDisplayText +import chat.simplex.common.views.chat.item.itemDisplay import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @@ -267,23 +267,22 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple if (sel.first < prefix.length) { sb.append(prefix, sel.first, minOf(prefix.length, sel.last + 1)) } - val formattedText = ci.formattedText ?: run { + val parts = itemDisplay(ci, linkMode)?.parts ?: run { val start = (sel.first - prefix.length).coerceAtLeast(0).coerceAtMost(ci.text.length) val end = (sel.last + 1 - prefix.length).coerceAtMost(ci.text.length) if (start < end) sb.append(ci.text, start, end) return sb.toString() } var displayOffset = prefix.length - for (ft in formattedText) { - val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + for ((segDisplay, source) in parts) { val displayEnd = displayOffset + segDisplay.length val overlapStart = maxOf(displayOffset, sel.first) val overlapEnd = minOf(displayEnd, sel.last + 1) if (overlapStart < overlapEnd) { - if (ft.text.length == segDisplay.length) { - sb.append(ft.text, overlapStart - displayOffset, overlapEnd - displayOffset) + if (source.length == segDisplay.length) { + sb.append(source, overlapStart - displayOffset, overlapEnd - displayOffset) } else { - sb.append(ft.text) + sb.append(source) } } displayOffset = displayEnd @@ -293,12 +292,11 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple // Snaps a boundary offset to include full transformed segments. private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int { - val formattedText = ci.formattedText ?: return offset + val parts = itemDisplay(ci, linkMode)?.parts ?: return offset var displayOffset = itemPrefixText(ci).length - for (ft in formattedText) { - val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + for ((segDisplay, source) in parts) { val displayEnd = displayOffset + segDisplay.length - if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) { + if (offset > displayOffset && offset < displayEnd && source.length != segDisplay.length) { return if (expandRight) displayEnd else displayOffset } displayOffset = displayEnd @@ -425,18 +423,55 @@ private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? { return idx } -class ItemSelection( +class TextSelection( val highlightRange: IntRange?, val positionModifier: Modifier, - val onTextLayoutResult: ((TextLayoutResult) -> Unit)? + val onTextLayoutResult: (TextLayoutResult) -> Unit ) +class SelectionText(var base: Int) { + var bounds: Rect? = null + var layout: TextLayoutResult? = null +} + +class ItemSelection( + private val highlightRange: IntRange?, + private val texts: MutableMap?, + private val bases: List +) { + fun text(index: Int): TextSelection? { + val texts = texts ?: return null + val base = bases.getOrNull(index) ?: return null + val t = texts.getOrPut(index) { SelectionText(base) } + t.base = base + val highlight = highlightRange?.let { (it.first - base).coerceAtLeast(0) .. (it.last - base) }?.takeUnless { it.isEmpty() } + return TextSelection( + highlight, + Modifier.onGloballyPositioned { + val pos = it.positionInWindow() + t.bounds = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) + }, + { t.layout = it } + ) + } +} + +private class TextHit(val text: SelectionText, val bounds: Rect, val layout: TextLayoutResult, val offset: Int) + +private fun textAt(texts: Collection, x: Float, y: Float): TextHit? = + texts.mapNotNull { t -> + val b = t.bounds ?: return@mapNotNull null + val l = t.layout?.takeIf { it.layoutInput.text.isNotEmpty() } ?: return@mapNotNull null + Triple(t, b, l) + }.minByOrNull { (_, b, _) -> maxOf(b.top - y, 0f, y - b.bottom) * 4 + maxOf(b.left - x, 0f, x - b.right) } + ?.let { (t, b, l) -> TextHit(t, b, l, l.getOffsetForPosition(Offset(x - b.left, y - b.top))) } + // Sets up selection tracking for a text item: anchor/focus offset resolution, // highlight range computation, and position/layout result capture. @Composable -fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean): ItemSelection { - val boundsState = remember { mutableStateOf(null) } - val layoutResultState = remember { mutableStateOf(null) } +fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean, bases: List): ItemSelection { + val texts = remember { HashMap() } + texts.keys.retainAll { it < bases.size } if (selectionManager != null && selectionIndex >= 0 && !isLive) { val isAnchor = remember(selectionIndex) { @@ -444,12 +479,8 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, } LaunchedEffect(isAnchor.value) { if (!isAnchor.value) return@LaunchedEffect - val bounds = boundsState.value ?: return@LaunchedEffect - val layout = layoutResultState.value ?: return@LaunchedEffect - val offset = layout.getOffsetForPosition( - Offset(selectionManager.anchorWindowX - bounds.left, selectionManager.anchorWindowY - bounds.top) - ) - selectionManager.setAnchorOffset(offset) + val hit = textAt(texts.values, selectionManager.anchorWindowX, selectionManager.anchorWindowY) ?: return@LaunchedEffect + selectionManager.setAnchorOffset(hit.text.base + hit.offset) } val isFocus = remember(selectionIndex) { @@ -459,10 +490,9 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, LaunchedEffect(Unit) { snapshotFlow { selectionManager.focusWindowY to selectionManager.focusWindowX } .collect { (py, px) -> - val bounds = boundsState.value ?: return@collect - val layout = layoutResultState.value ?: return@collect - val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top)) - val charBox = layout.getBoundingBox(offset.coerceIn(0, layout.layoutInput.text.length - 1)) + val hit = textAt(texts.values, px, py) ?: return@collect + val bounds = hit.bounds + val charBox = hit.layout.getBoundingBox(hit.offset.coerceIn(0, hit.layout.layoutInput.text.length - 1)) val ls = selectionManager.listState?.value val itemInfo = ls?.layoutInfo?.visibleItemsInfo?.find { it.index == selectionIndex } val charRect = if (ls != null && itemInfo != null) { @@ -474,7 +504,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, bottom = bounds.top + charBox.bottom - itemWindowY ) } else Rect.Zero - selectionManager.updateFocusOffset(offset, charRect) + selectionManager.updateFocusOffset(hit.text.base + hit.offset, charRect) } } } @@ -484,18 +514,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) } }.value } else null - val positionModifier = if (selectionManager != null) { - Modifier.onGloballyPositioned { - val pos = it.positionInWindow() - boundsState.value = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) - } - } else Modifier - - val onTextLayoutResult: ((TextLayoutResult) -> Unit)? = if (selectionManager != null) { - { layoutResultState.value = it } - } else null - - return ItemSelection(highlightRange, positionModifier, onTextLayoutResult) + return ItemSelection(highlightRange, if (selectionManager != null) texts else null, bases) } // Sets up full-item selection for emoji items (no character-level tracking). diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index ec8b545647..ef847c93cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -146,13 +146,16 @@ private fun TextPreview(text: String, linkMode: SimplexLinkMode, markdown: Boole val uriHandler = LocalUriHandler.current Column { SelectionContainer(Modifier.fillMaxWidth()) { + val parsed = if (markdown) remember(text) { parseToMarkdownText(text) } else null MarkdownText( text, - formattedText = if (markdown) remember(text) { parseToMarkdown(text) } else null, + formattedText = parsed?.formattedText, + markdownBlocks = parsed?.markdownBlocks, toggleSecrets = false, modifier = Modifier.fillMaxHeight().padding(horizontal = DEFAULT_PADDING), linkMode = linkMode, uriHandler = uriHandler, - style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp) + style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), + blockLayout = true ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index fd422b649a..ca370c1b52 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -61,6 +61,7 @@ fun FramedItemView( MarkdownText( qi.text, qi.formattedText, + qi.markdownBlocks, toggleSecrets = true, maxLines = lines, overflow = TextOverflow.Ellipsis, @@ -457,11 +458,12 @@ fun CIMarkdownText( ) { val chatInfo = chat.chatInfo val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text - val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true) + val bases = remember(ci, linkMode) { itemDisplay(ci, linkMode)?.bases ?: listOf(0) } + val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true, bases) - Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp).then(selection.positionModifier)) { + Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { MarkdownText( - text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, + text, if (text.isEmpty()) emptyList() else ci.formattedText, if (text.isEmpty()) null else ci.markdownBlocks, toggleSecrets = true, sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, mentions = ci.mentions, userMemberId = when { @@ -470,8 +472,9 @@ fun CIMarkdownText( }, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, stripLink = stripLink, - selectionRange = selection.highlightRange, - onTextLayoutResult = selection.onTextLayoutResult + selection = selection, + blockLayout = true, + cardColor = if (ci.chatDir.sent) MaterialTheme.appColors.sentQuote else MaterialTheme.appColors.receivedQuote ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt new file mode 100644 index 0000000000..75a778e567 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt @@ -0,0 +1,248 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.takeOrElse +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.* +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun MarkdownBlocks( + formattedText: List, + blocks: List, + style: TextStyle, + modifier: Modifier, + cardColor: Color, + blockText: @Composable (index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier, softWrap: Boolean, last: Boolean) -> Unit, + metaLine: @Composable () -> Unit +) { + val roundness = remember { appPreferences.chatItemRoundness.state }.value.coerceIn(0f, 1f) + val cardShape = RoundedCornerShape(12.dp * roundness) + val lineColor = MaterialTheme.colors.secondary.copy(alpha = 0.35f) + val textColor = style.color.takeOrElse { MaterialTheme.colors.onSurface } + val fontSize = style.fontSize.takeOrElse { 16.sp } + val lineHeight = style.lineHeight.takeOrElse { 22.sp } + val starts = remember(blocks) { blocks.runningFold(0) { n, b -> n + b.textRanges.size } } + val lastIsText = when (blocks.lastOrNull()) { + is MarkdownBlock.Paragraph, is MarkdownBlock.Heading, is MarkdownBlock.Quote, is MarkdownBlock.ItemList -> true + else -> false + } + @Composable + fun BlockText(index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier = Modifier, softWrap: Boolean = true) { + blockText(index, range, style, modifier, softWrap, lastIsText && index == starts.last() - 1) + } + Column(modifier) { + blocks.forEachIndexed { k, block -> + if (k > 0 && paragraphBreak(formattedText, blocks[k - 1].end, block.start)) { + Spacer(Modifier.height(with(LocalDensity.current) { (lineHeight.toPx() / 2).toDp() })) + } + val start = starts[k] + when (block) { + is MarkdownBlock.Paragraph -> BlockText(start, block.content, style) + is MarkdownBlock.Heading -> { + val scale = when (block.level) { + 1 -> 1.5f + 2 -> 1.3f + 3 -> 1.15f + else -> 1f + } + BlockText(start, block.content, style.copy(fontSize = fontSize * scale, lineHeight = lineHeight * scale, fontWeight = FontWeight.Bold)) + } + is MarkdownBlock.Quote -> Row(Modifier.height(IntrinsicSize.Min)) { + Box(Modifier.width(4.dp).fillMaxHeight().clip(RoundedCornerShape(2.dp)).background(textColor.copy(alpha = if (MaterialTheme.colors.isLight) 0.16f else 0.22f))) + Spacer(Modifier.width(10.dp)) + BlockText(start, block.content, style.copy(color = textColor.copy(alpha = 0.7f))) + } + is MarkdownBlock.ItemList -> listEntries(block.items, 0).forEachIndexed { j, (entry, level) -> + Row(Modifier.padding(start = 20.dp * level)) { + Text(entry.number ?: "•", Modifier.widthIn(min = 14.dp), style = style) + Spacer(Modifier.width(6.dp)) + BlockText(start + j, entry.content, style, Modifier.weight(1f, fill = false)) + } + } + is MarkdownBlock.CodeBlock -> CodeBlockCard(cardShape, cardColor, roundness, codeText(formattedText, block.content)) { m -> + BlockText(start, block.content, style.copy(fontFamily = FontFamily.Monospace, fontSize = fontSize * 0.875f, lineHeight = lineHeight * 0.9f), m, softWrap = false) + } + is MarkdownBlock.Table -> { + val tableRows = listOf(block.header) + block.rows + val rowStarts = tableRows.runningFold(start) { n, row -> n + row.size } + Box(Modifier.horizontalScroll(rememberScrollState())) { + TableGrid( + block.columns, tableRows.map { it.size }, cardColor, MaterialTheme.colors.secondary.copy(alpha = 0.1f), lineColor, + Modifier.clip(cardShape).border(1.dp, lineColor, cardShape) + ) { + tableRows.forEachIndexed { r, row -> + row.forEachIndexed { c, cell -> + BlockText( + rowStarts[r] + c, cell, if (r == 0) style.copy(fontWeight = FontWeight.Bold) else style, + Modifier.padding(horizontal = 12.dp, vertical = 4.dp), softWrap = false + ) + } + } + } + } + } + is MarkdownBlock.Rule -> Box(Modifier.padding(vertical = 6.dp).fillMaxWidth().height(1.dp).background(lineColor)) + } + } + if (!lastIsText) metaLine() + } +} + +private val MarkdownBlock.start: Int get() = when (this) { + is MarkdownBlock.Rule -> mark.from + else -> textRanges.firstOrNull()?.range?.from ?: 0 +} + +private val MarkdownBlock.end: Int get() = when (this) { + is MarkdownBlock.Rule -> mark.to + else -> textRanges.lastOrNull()?.range?.to ?: 0 +} + +private fun paragraphBreak(fts: List, from: Int, to: Int): Boolean = + (from.coerceIn(0, fts.size) until to.coerceIn(0, fts.size)).any { (fts[it].format as? Format.Markup)?.token is BlockToken.ParagraphBreak } + +private fun codeText(fts: List, r: SegmentRange): String = + (r.from.coerceIn(0, fts.size) until r.to.coerceIn(0, fts.size)).joinToString("") { i -> + when (val f = fts[i].format) { + is Format.Markup -> f.token.drawnText + is Format.Escape -> "" + else -> fts[i].text + } + } + +@Composable +private fun CodeBlockCard(shape: Shape, color: Color, roundness: Float, copyText: String, content: @Composable (Modifier) -> Unit) { + var tapped by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val clipboard = LocalClipboardManager.current + Box( + Modifier + .fillMaxWidth() + .clip(shape) + .background(color) + .hoverable(interactionSource) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + if (waitForUpOrCancellation() != null) tapped = !tapped + } + } + ) { + content(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp)) + if (hovered || tapped) { + Box(Modifier.matchParentSize(), contentAlignment = Alignment.TopEnd) { + Box( + Modifier + .padding(4.dp) + .size(28.dp) + .clip(RoundedCornerShape(8.dp * roundness)) + .background(color) + .clickable { + clipboard.setText(AnnotatedString(copyText)) + showToast(generalGetString(MR.strings.copied)) + }, + contentAlignment = Alignment.Center + ) { + Icon(painterResource(MR.images.ic_content_copy), stringResource(MR.strings.copy_verb), Modifier.size(18.dp), tint = MaterialTheme.colors.primary) + } + } + } + } +} + +private class TableGridLines { + var xs: List = emptyList() + var ys: List = emptyList() +} + +@Composable +private fun TableGrid( + columns: List, + rowSizes: List, + headerColor: Color, + stripeColor: Color, + lineColor: Color, + modifier: Modifier, + content: @Composable () -> Unit +) { + val lines = remember { TableGridLines() } + Layout( + content, + modifier.drawBehind { + val xs = lines.xs + val ys = lines.ys + if (ys.size < 2) return@drawBehind + drawRect(headerColor, Offset.Zero, Size(size.width, ys[1].toFloat())) + for (r in 2 until ys.size - 1 step 2) { + drawRect(stripeColor, Offset(0f, ys[r].toFloat()), Size(size.width, (ys[r + 1] - ys[r]).toFloat())) + } + val stroke = 1.dp.toPx() + xs.drop(1).dropLast(1).forEach { x -> drawLine(lineColor, Offset(x.toFloat(), 0f), Offset(x.toFloat(), size.height), stroke) } + ys.drop(1).dropLast(1).forEach { y -> drawLine(lineColor, Offset(0f, y.toFloat()), Offset(size.width, y.toFloat()), stroke) } + } + ) { measurables, _ -> + val cells = rowSizes.flatMapIndexed { r, n -> List(n) { c -> r to c } }.take(measurables.size) + val widths = IntArray(maxOf(columns.size, rowSizes.maxOrNull() ?: 0)) + cells.forEachIndexed { k, (_, c) -> widths[c] = maxOf(widths[c], measurables[k].maxIntrinsicWidth(Constraints.Infinity)) } + val placeables = cells.mapIndexed { k, (_, c) -> measurables[k].measure(Constraints(maxWidth = widths[c])) } + val heights = IntArray(rowSizes.size) + placeables.forEachIndexed { k, p -> heights[cells[k].first] = maxOf(heights[cells[k].first], p.height) } + val xs = widths.toList().runningFold(0) { a, w -> a + w } + val ys = heights.toList().runningFold(0) { a, h -> a + h } + lines.xs = xs + lines.ys = ys + layout(xs.last(), ys.last()) { + placeables.forEachIndexed { k, p -> + val (r, c) = cells[k] + val free = widths[c] - p.width + val x = xs[c] + when (columns.getOrNull(c)) { + TableAlign.Right -> free + TableAlign.Center -> free / 2 + else -> 0 + } + p.place(x, ys[r]) + } + } + } +} 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 c198451464..9b3162744d 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 @@ -12,8 +12,20 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.* import androidx.compose.ui.platform.* @@ -22,11 +34,14 @@ import androidx.compose.ui.text.AnnotatedString.Range import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.appColors +import chat.simplex.common.views.chat.ItemSelection import chat.simplex.common.views.chat.SelectionHighlightColor import chat.simplex.common.views.helpers.* import chat.simplex.res.* @@ -53,14 +68,17 @@ private fun showFullProfileDescription(description: String) { ModalManager.end.showModalCloseable { _ -> ColumnWithScrollBar { AppBarTitle(generalGetString(MR.strings.profile_description__field)) + val parsed = parseToMarkdownText(description) MarkdownText( description, - parseToMarkdown(description), + parsed?.formattedText, + parsed?.markdownBlocks, toggleSecrets = true, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), uriHandler = LocalUriHandler.current, linkMode = chatModel.simplexLinkMode.value, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING), + blockLayout = true, ) } } @@ -85,10 +103,10 @@ private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = AnnotatedString(".", SpanStyle(fontWeight = w)) // Display text for a single formatted segment — must be coordinated with MarkdownText. -fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLinkMode): String = +fun segmentDisplayText(ft: FormattedText, mentions: Map?, linkMode: SimplexLinkMode): String = when (ft.format) { is Format.Mention -> { - val mention = ci.mentions?.get(ft.format.memberName) + val mention = mentions?.get(ft.format.memberName) if (mention?.memberRef != null) { val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})" @@ -104,13 +122,37 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin if (t != null) "$t ${ft.format.viaHosts}" else ft.text } is Format.Command -> ft.text + is Format.Escape -> "" + is Format.Markup -> ft.format.token.drawnText else -> ft.text } -// Full display text for a chat item — joins segment display texts. -fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String { - val formattedText = ci.formattedText ?: return ci.text - return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) } +class ItemDisplay(val parts: List>, val bases: List) + +fun itemDisplay(ci: ChatItem, linkMode: SimplexLinkMode): ItemDisplay? { + val fts = ci.formattedText ?: return null + val segment = { ft: FormattedText -> + val display = segmentDisplayText(ft, ci.mentions, linkMode) + display to if (ft.format is Format.Markup) display else ft.text + } + val blocks = ci.markdownBlocks ?: return ItemDisplay(fts.map(segment), listOf(0)) + val parts = mutableListOf>() + var offset = itemPrefixText(ci).length + val bases = blockTextRanges(blocks).mapIndexed { i, t -> + if (i > 0) { + val separator = if (t.inRow) "\t" else "\n" + parts += separator to separator + offset += 1 + } + val base = if (i == 0) 0 else offset + for (j in t.range.from.coerceIn(0, fts.size) until t.range.to.coerceIn(0, fts.size)) { + val part = segment(fts[j]) + parts += part + offset += part.first.length + } + base + } + return ItemDisplay(parts, bases) } // Display-only prefix rendered before ci.text (e.g. "Spam: " for reports). @@ -120,11 +162,12 @@ fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent) else -> "" } -// Text transformations in MarkdownText must match itemSegmentDisplayText above +// Text transformations in MarkdownText must match segmentDisplayText above @Composable fun MarkdownText ( text: CharSequence, formattedText: List? = null, + markdownBlocks: List? = null, sender: String? = null, meta: CIMeta? = null, chatTTL: Int? = null, @@ -145,15 +188,44 @@ fun MarkdownText ( showTimestamp: Boolean = true, prefix: AnnotatedString? = null, stripLink: String? = null, - selectionRange: IntRange? = null, - onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null + selection: ItemSelection? = null, + textIndex: Int = 0, + blockLayout: Boolean = false, + range: IntRange? = null, + softWrap: Boolean = true, + cardColor: Color? = null, + parentDirection: LayoutDirection? = null ) { val text = if (stripLink != null) stripTextLink(text.toString(), stripLink) else text val formattedText = if (stripLink != null) stripFormattedTextLink(formattedText, stripLink) else formattedText val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr } - val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) { + if (blockLayout && formattedText != null && markdownBlocks != null && range == null) { + val direction = LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides textLayoutDirection) { + MarkdownBlocks( + formattedText, markdownBlocks, style, modifier, cardColor ?: MaterialTheme.appColors.receivedQuote, + blockText = { index, r, blockStyle, blockModifier, blockSoftWrap, last -> + MarkdownText( + text, formattedText, markdownBlocks, sender = if (index == 0) sender else null, meta = if (last) meta else null, chatTTL = chatTTL, + mentions = mentions, userMemberId = userMemberId, toggleSecrets = toggleSecrets, sendCommandMsg = sendCommandMsg, style = blockStyle, + uriHandler = uriHandler, senderBold = senderBold, modifier = blockModifier, linkMode = linkMode, onLinkLongClick = onLinkLongClick, + showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = if (index == 0) prefix else null, + selection = selection, textIndex = index, range = r.from until r.to, softWrap = blockSoftWrap, parentDirection = direction + ) + }, + metaLine = { + MarkdownText( + text, formattedText, markdownBlocks, meta = meta, chatTTL = chatTTL, toggleSecrets = toggleSecrets, style = style, + linkMode = linkMode, showViaProxy = showViaProxy, showTimestamp = showTimestamp, range = IntRange.EMPTY + ) + } + ) + } + return + } + val reserve = if (textLayoutDirection != (parentDirection ?: LocalLayoutDirection.current) && meta != null) { "\n" } else if (meta != null) { reserveSpaceForMeta(meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp) @@ -196,6 +268,7 @@ fun MarkdownText ( } ) } + val textSelection = selection?.text(textIndex) if (formattedText == null) { var selectableEnd = 0 val annotatedText = buildAnnotatedString { @@ -210,9 +283,9 @@ fun MarkdownText ( } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } - if (onTextLayoutResult != null) { - SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } + if (textSelection != null) { + SelectableText(annotatedText, style = style, modifier = modifier.then(textSelection.positionModifier), maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = textSelection.onTextLayoutResult) } else { Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) } @@ -222,29 +295,38 @@ fun MarkdownText ( var hasSecrets = false var hasCommands = false var hasModals = false + val hiddenSecrets = mutableListOf() val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) if (prefix != null) append(prefix) - for ((i, ft) in formattedText.withIndex()) { + val headings = if (range == null && markdownBlocks != null) headingSegments(formattedText.size, markdownBlocks) else null + fun appendSecret(s: String) { + val start = length + withStyle(hiddenSecretStyle) { append(s) } + hiddenSecrets += start until length + } + for (i in range?.let { it.first.coerceAtLeast(0) .. minOf(it.last, formattedText.lastIndex) } ?: formattedText.indices) { + val ft = formattedText[i] + val heading = headings?.get(i) == true + if (heading) pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) if (ft.format == null) append(ft.text) else when(ft.format) { - is Format.Bold -> withStyle(ft.format.style) { append(ft.text) } + is Format.Bold -> withStyle(if (markdownBlocks != null && ft.format.singleStar) Format.Italic().style else ft.format.style) { append(ft.text) } is Format.Italic -> withStyle(ft.format.style) { append(ft.text) } is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) } is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) } is Format.Small -> withStyle(ft.format.style) { append(ft.text) } is Format.Colored -> withStyle(ft.format.style) { append(ft.text) } is Format.Secret -> { - val ftStyle = ft.format.style if (toggleSecrets) { hasSecrets = true val key = i.toString() withAnnotation(tag = "SECRET", annotation = key) { - if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } + if (showSecrets[key] == true) append(ft.text) else appendSecret(ft.text) } } else { - withStyle(ftStyle) { append(ft.text) } + appendSecret(ft.text) } } is Format.Mention -> { @@ -290,7 +372,7 @@ fun MarkdownText ( is Format.HyperLink -> { hasLinks = true val ftStyle = Format.linkStyle - withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) { + withAnnotation(tag = if (ft.format.isEmail) "OTHER_URL" else "WEB_URL", annotation = ft.format.linkUri) { withStyle(ftStyle) { append(ft.format.showText ?: ft.text) } } } @@ -338,8 +420,15 @@ fun MarkdownText ( withStyle(ftStyle) { append(ft.text) } } } + is Format.Escape -> {} + is Format.Markup -> append( + if (range != null) ft.format.token.drawnText + else if (markdownBlocks != null) ft.format.token.previewText + else ft.text + ) is Format.Unknown -> append(ft.text) } + if (heading) pop() } selectableEnd = this.length if (meta?.isLive == true) { @@ -350,10 +439,21 @@ fun MarkdownText ( withStyle(reserveTimestampStyle) { append("\n" + metaText) } else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } + val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } + val layoutResult = remember { mutableStateOf(null) } + val onTextLayout: (TextLayoutResult) -> Unit = { r -> + layoutResult.value = r + textSelection?.onTextLayoutResult?.invoke(r) + } + val textModifier = modifier + .then(textSelection?.positionModifier ?: Modifier) + .then(if (hiddenSecrets.isEmpty()) Modifier else Modifier.drawWithContent { + drawContent() + drawSecretPattern(hiddenSecrets, layoutResult.value) + }) if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null) || hasModals) { val icon = remember { mutableStateOf(PointerIcon.Text) } - ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, + ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = textModifier.pointerHoverIcon(icon.value), softWrap = softWrap, maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> @@ -410,13 +510,38 @@ fun MarkdownText ( || annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "MODAL", start = offset, end = offset) }, - onTextLayout = { onTextLayoutResult?.invoke(it) } + onTextLayout = onTextLayout ) } else { - if (onTextLayoutResult != null) { - SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + if (textSelection != null) { + SelectableText(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayout) } else { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + Text(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf(), onTextLayout = onTextLayout) + } + } + } + } +} + +private val hiddenSecretStyle = SpanStyle(color = Color.Transparent) + +private val secretTile: ImageBitmap? by lazy { MR.images.vertical_logo.toComposeImageBitmap() } + +private fun ContentDrawScope.drawSecretPattern(ranges: List, layout: TextLayoutResult?) { + val tile = secretTile ?: return + if (layout == null) return + val brush = ShaderBrush(ImageShader(tile, TileMode.Repeated, TileMode.Repeated)) + val tileScale = 8.dp.toPx() / tile.width + val saturation = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.65f) }) + val textLength = layout.layoutInput.text.length + ranges.forEach { r -> + val path = layout.getPathForRange(r.first.coerceIn(0, textLength), (r.last + 1).coerceIn(0, textLength)) + val bounds = path.getBounds() + val side = (bounds.width + bounds.height) / tileScale + clipPath(path) { + rotate(45f, bounds.center) { + scale(tileScale, bounds.center) { + drawRect(brush, Offset(bounds.center.x - side / 2, bounds.center.y - side / 2), Size(side, side), alpha = 0.35f, colorFilter = saturation) } } } @@ -500,6 +625,7 @@ private fun SelectableText( text: AnnotatedString, style: TextStyle, modifier: Modifier = Modifier, + softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, selectionRange: IntRange? = null, @@ -511,6 +637,7 @@ private fun SelectableText( text = text, modifier = modifier.pointerHoverIcon(PointerIcon.Text).then(selectionHighlight(selectionRange, text.length, layoutResult)), style = style, + softWrap = softWrap, maxLines = maxLines, overflow = overflow, onTextLayout = { @@ -624,7 +751,9 @@ fun stripFormattedTextLink(ft: List?, link: String): List= 0 && result[i].format == null && result[i].text.endsWith("\n")) { + if (i >= 0 && result[i].format is Format.Markup) { + result.removeAt(i) + } else if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) { result[i] = FormattedText(result[i].text.dropLast(1), null) if (result[i].text.isEmpty()) result.removeAt(result.lastIndex) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 7e11fdfb97..89d2dfb811 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -277,6 +277,7 @@ fun ChatPreviewView( MarkdownText( text, formattedText, + if (formattedText == null) null else ci.markdownBlocks, sender = when { cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName else -> null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index 8ae27ca17f..7d0851e65b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -132,13 +132,16 @@ fun PlainTextEditor( @Serializable data class ParsedFormattedText( - val formattedText: List? = null + val formattedText: List? = null, + val markdownBlocks: List? = null ) -fun parseToMarkdown(text: String): List? { +fun parseToMarkdown(text: String): List? = parseToMarkdownText(text)?.formattedText + +fun parseToMarkdownText(text: String): ParsedFormattedText? { val formatted = chatParseMarkdown(text) return try { - json.decodeFromString(ParsedFormattedText.serializer(), formatted).formattedText + json.decodeFromString(ParsedFormattedText.serializer(), formatted) } catch (e: Exception) { Log.e(TAG, "Failed to parse into markdown: " + e.stackTraceToString()) null diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..f916e43ea99bbda5ae2bba007dc73ac5a77c8ea9 GIT binary patch literal 1234 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqEa{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I?aN0%imoq%CcqSRjz%EbxddW?Q&?xfOIj~R9FF-xv3?I3Kh9IdBs*0wn|_XRzNmLSYJs2tfVB{Rw=?aK*2e`C{@8s z&p^*W$&O1wLBXadCCw_x#SN+*$g@?-C@Cqh($_C9FV`zK*2^zS*Eh7ZwA42+(l;{F z1**_3uFNY*tkBIXR)!b?Gsh*hIJqdZpd>RtPXT0ZVp4u-iLH_n$Rap^xU(cP4PjGW zG1OZ?59)(t^bPe4^s#A6t;oco4I~562KE=kIvbE-R*^xe#rZj)0j?1ok@jPI{ed6ij7$PAl+HdcDm_guZ{q+zHC5s>IKHUmCc6RUB zq1n;Rw@y#?Pe5G*->#Pr7U`|C^33e=aOAz!5F$1ClFIYf&5!Th-TnC9L7x5lj`X|N z{ASYc^4PO_UcmGnJ_n_QKMML5&JgcLY(2J1Hq5vaU>p5IWx2^6@tu+_ zcWPrcZ>~EK#+{T`*e4-;-N0M=?eiX$azFjxd6TA`nd|b&PibHUB)wIukE-|oopnYXt(t9jmHI>-IvAhdr_44 Zw_a>YvSZQGceg<~)zj6_Wt~$(69AmVcuD{O literal 0 HcmV?d00001 diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..bb35878f0c4aec928c3e16b4d10603dfc2a14748 GIT binary patch literal 1617 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I`{x0%imor0saV%ts)_S>O>_%)r1c48n{Iv*t)JFfg6S42dX-@b$4u&d=3LOvz75 z)vL%Y0O?||sjvbvb5lza6)JLb@`|l0Y?Z(&tblBgu)dN4SV>8?tx|+H?jfSfFg)+ zA4CH}eP~{0i5*M}nlQSq2!uSs8e~Cq4gN)$slb2)yUP&jEMzex^&sC_1!PvF=0vz; z=BDNqgZyF)bQo3%bdApWIr&Aw1&PV2c0hZ;T9Aa$RfDaG#AOYV1XQDqJ}9n{;vSN) zz@osEWyfWs4^O3bT#H{kng>kN$(}BbAr^w6!TYm=14aJ5t6l%|;*l+#nW0kbp6s(d z3km~YEKt}{Cd7VMCWhtvntz;D9VusYWY;OaA zByogiaiKfoYH7f+x{psgA+gg5&h{$>*JJ1i;JQTS)Y`{$NNWO9q7#AFix?`Ztq?9b!9ac`yU z+pocMcC5pb>LRBo@e(tN#kiROSwKaBkUWE^am z#r!NlTeUmd+-yhep)>K8Pmb_T3Ee1D%zAA7ri|}eUa2{!<-ho>>X<(3*bLoE&*EEH zGH*R>xhiOUkn6}h!GpSo%?>>hNuP85Xw_8fKhtzsRCYW6ZC5#x#r^+Oa~x}8wgc0- z-@n=7?C0!$ZL9fE!k&YNb8pM?XFBbXCA-C}YwbVoh&y-c$@$G;=b8%)BGYQ?rUvJ& z@|yqpj;Qpc2HDi9I{F(|@Pz4REsyijO)a~>y;PW?Y{gIGzpU96|F4|c)AblsP!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I`{#0%imoq)m`tVjYm;EbxddW?XQ2>tmIipR1RclAn~S zSCLx)(#2p?VFhI7rj{fsROII56h?X&sHg;q@=(~U%$M(T(8_%FTW^V-_X+1Qs2Nx-^fT8 zs6w~6GOr}DLN~8i8Da>`9GBGMxocyBTg2d!hJD@#aEl5J>s=?Mo;<5%w0;&~S2ZoS-?$NXYbm!&85Si-7?*y=IAIZWLLl5_CyHc&UTSHYTncpN`MX{qXkP z2ODGS-Q_nHHGDU*HhypOxo&^4gMDQ7owCDz3HSf?D`#)ap8t5IYKSWv&a8aKkp6M|-K7HaH#4u(Ikr-vNKJIl zcecwFDZ)w8Pr}`#!%j}?-kfv!gJJK4jb3XEpS;}u+~rkFSoNAuCeNmLBs9i;Uz_?u z@ASIU4Xxb|H$A&vdq`we1Lx=6Gj;^UNINDA%Oq{ywdT^6KPSJMA64JveTu3 zk@`MvK7E1rrm9bCy!$L%BEE4;+>aLa`N8HF9tLdPak#g(0h_5G)?SslFv)fnHErX>_-IAUE#dE`!H?fy9%a1&mZeR7e z@?MO&*{53#7Y~08dp`S%z~|zg+cpLNi`uq|>z=7w_H2sKX}^zdYYg6G{tR1U^)K=3 zMrOh1NvXY)E^YXEQR#bCqLJTu0ZB3CB|@)0$n}`u#m%d> zXH5Kgi1mc#1$S3Dr6V_IwH!KMJ?+OO2d;;K6N;y)MJ#JvzVi&P=#+jD@mdwlT`onZ zethAz>9R_2zS(9SX8O|cqraPK4A;*Xckz}ok=4b#)h5!9{|HOxHuWyE+j*|-)uCfm z0usgFj$OE6u;_>Ts+o%><#PR>m%UZnR?R(5Aokbl=-QI6_XKWrpBLDEZh=Kr-??W@ zVR=8)tq)ww5w36Cd638Yhw3%M<6M`y%^Wzr&zp;LOJr-eH5F|+&l_w$b@vQuleQh{ z8q=rkWk0kgNy6FW!tvV)O|@&)i*6pPc%*0L8l=FX`80vC^HZAQcdb`jZPHRbzdroi zrx~?zdgZU;S5~11gr4uSH&p$3*fVSCh4KTPMRtCUA0D_|ocB{qn&pst=)AZapc>25 L)z4*}Q$iB}rr7 "},{"text":"q1"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"text":"q2"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":">"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"format":{"type":"bold","singleStar":false},"text":"q3"}],"markdownBlocks":[{"type":"quote","content":{"from":1,"to":10}}]}""") + + private val codeBlockAndRule = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"fenceMark","language":"bash"}},"text":"```bash"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"ls -l"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"pwd"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"fenceMark"}},"text":"```"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"ruleMark"}},"text":"---"}],"markdownBlocks":[{"type":"codeBlock","language":"bash","content":{"from":2,"to":6}},{"type":"rule","mark":{"from":9,"to":10}}]}""") + + private val tableAligned = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"a"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"b"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"c"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"d"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"delimiterRow","columns":["none","left","right","center"]}},"text":"|---|:--|--:|:-:|"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"xxx"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"format":{"type":"bold","singleStar":false},"text":"yyy"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"zzz"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"}],"markdownBlocks":[{"type":"table","columns":["none","left","right","center"],"header":[{"from":1,"to":2},{"from":3,"to":4},{"from":5,"to":6},{"from":7,"to":8}],"rows":[[{"from":13,"to":14},{"from":15,"to":16},{"from":17,"to":18}]]}]}""") + + private val gaps = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":2}},"text":"\n\n"},{"text":"a"},{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":3}},"text":"\n\n\n"},{"format":{"type":"markup","token":{"type":"headingMark","level":1}},"text":"# "},{"text":"h"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"}],"markdownBlocks":[{"type":"paragraph","content":{"from":1,"to":2}},{"type":"heading","level":1,"content":{"from":4,"to":5}}]}""") + + private val escapes = parsed("""{"formattedText":[{"text":"text "},{"format":{"type":"escape"},"text":"\\"},{"text":"*not"},{"format":{"type":"escape"},"text":"\\"},{"text":"*\nmore"}]}""") + + @Test + fun testDecodeTokens() { + val tokens = (headingAndParagraph.formattedText!! + nestedList.formattedText!! + codeBlockAndRule.formattedText!! + tableAligned.formattedText!! + gaps.formattedText!!) + .mapNotNull { (it.format as? Format.Markup)?.token } + assertEquals(1, tokens.filterIsInstance().first().level) + assertEquals(listOf(null, null, "2."), tokens.filterIsInstance().map { it.number }) + assertEquals(listOf("bash", null), tokens.filterIsInstance().map { it.language }) + assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), tokens.filterIsInstance().first().columns) + assertEquals(listOf(2, 3), tokens.filterIsInstance().map { it.lineBreaks }) + listOf( + BlockToken.LineBreak::class, BlockToken.SoftBreak::class, BlockToken.HardBreak::class, BlockToken.Indent::class, + BlockToken.RowMark::class, BlockToken.CellMark::class, BlockToken.RuleMark::class + ).forEach { kind -> assert(tokens.any { kind.isInstance(it) }) { "no $kind" } } + assertIs((quote.formattedText!![0].format as Format.Markup).token) + val bold = headingAndParagraph.formattedText!!.mapNotNull { it.format as? Format.Bold } + quote.formattedText!!.mapNotNull { it.format as? Format.Bold } + assertEquals(listOf(true, false), bold.map { it.singleStar }) + assertEquals(listOf("text ", "", "*not", "", "*\nmore"), escapes.formattedText!!.map(::display)) + assertNull(escapes.markdownBlocks) + } + + @Test + fun testDecodeBlocks() { + val heading = assertIs(headingAndParagraph.markdownBlocks!![0]) + assertEquals(1, heading.level) + assertEquals(1 to 3, heading.content.pair) + assertEquals(4 to 9, assertIs(headingAndParagraph.markdownBlocks!![1]).content.pair) + val list = assertIs(nestedList.markdownBlocks!!.single()) + assertEquals(listOf(null, "2."), list.items.map { it.number }) + assertEquals(7 to 8, list.items[0].items.single().content.pair) + assertEquals(1 to 10, assertIs(quote.markdownBlocks!!.single()).content.pair) + val code = assertIs(codeBlockAndRule.markdownBlocks!![0]) + assertEquals("bash", code.language) + assertEquals(2 to 6, code.content.pair) + assertEquals(9 to 10, assertIs(codeBlockAndRule.markdownBlocks!![1]).mark.pair) + val table = assertIs(tableAligned.markdownBlocks!!.single()) + assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), table.columns) + assertEquals(listOf(1 to 2, 3 to 4, 5 to 6, 7 to 8), table.header.map { it.pair }) + assertEquals(listOf(listOf(13 to 14, 15 to 16, 17 to 18)), table.rows.map { row -> row.map { it.pair } }) + } + + @Test + fun testBlockTextRanges() { + assertEquals(listOf(1 to 3, 4 to 9), texts(headingAndParagraph).map { it.first }) + assertEquals(listOf(1 to 5, 7 to 8, 10 to 11), texts(nestedList).map { it.first }) + assertEquals(listOf(1 to 10), texts(quote).map { it.first }) + assertEquals(listOf(2 to 6), texts(codeBlockAndRule).map { it.first }) + assertEquals( + listOf(1 to 2 to false, 3 to 4 to true, 5 to 6 to true, 7 to 8 to true, 13 to 14 to false, 15 to 16 to true, 17 to 18 to true), + texts(tableAligned) + ) + } + + @Test + fun testListEntryLevels() { + val list = assertIs(nestedList.markdownBlocks!!.single()) + assertEquals(listOf(1 to 0, 7 to 1, 10 to 0), listEntries(list.items, 0).map { (entry, level) -> entry.content.from to level }) + } + + @Test + fun testHeadingSegments() { + assertEquals(listOf(1, 2), headingSegments(9, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + assertEquals(listOf(4), headingSegments(6, gaps.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + assertEquals(listOf(1), headingSegments(2, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + } + + @Test + fun testTokenTexts() { + val tokens = listOf( + BlockToken.SoftBreak(), BlockToken.LineBreak(), BlockToken.HardBreak(), BlockToken.ParagraphBreak(3), BlockToken.Indent(), + BlockToken.HeadingMark(2), BlockToken.ListMark("1."), BlockToken.ListMark(null), BlockToken.QuoteMark(), BlockToken.FenceMark(null), + BlockToken.RowMark(), BlockToken.CellMark(), BlockToken.DelimiterRow(listOf(TableAlign.None)), BlockToken.RuleMark() + ) + assertEquals(listOf(" ", "\n", "\n", "\n\n\n", "", "", "", "", "", "", "", "", "", ""), tokens.map { it.drawnText }) + assertEquals(listOf(" ", "\n", " ", "\n", "", "", "1. ", "• ", "", "", "", " ", "", ""), tokens.map { it.previewText }) + assertEquals("", display(FormattedText("\\", Format.Escape()))) + assertEquals("Title x\nPara a b c", preview(headingAndParagraph)) + assertEquals("• x y\n• z\n2. w", preview(nestedList)) + } + + @Test + fun testItemDisplay() { + val item = ChatItem.getSampleData(text = "> q1\n> q2\n>\n> **q3**").copy(formattedText = quote.formattedText, markdownBlocks = quote.markdownBlocks) + val display = itemDisplay(item, SimplexLinkMode.DESCRIPTION)!! + assertEquals("q1 q2\n\nq3", display.parts.joinToString("") { it.first }) + assertEquals(listOf(0), display.bases) + val table = item.copy(formattedText = tableAligned.formattedText, markdownBlocks = tableAligned.markdownBlocks) + val tableDisplay = itemDisplay(table, SimplexLinkMode.DESCRIPTION)!! + assertEquals("a\tb\tc\td\nxxx\tyyy\tzzz", tableDisplay.parts.joinToString("") { it.first }) + assertEquals(listOf(0, 2, 4, 6, 8, 12, 16), tableDisplay.bases) + val plain = item.copy(formattedText = escapes.formattedText, markdownBlocks = null) + val plainDisplay = itemDisplay(plain, SimplexLinkMode.DESCRIPTION)!! + assertEquals(listOf("text " to "text ", "" to "\\", "*not" to "*not", "" to "\\", "*\nmore" to "*\nmore"), plainDisplay.parts) + assertEquals(listOf(0), plainDisplay.bases) + } + + private val SegmentRange.pair: Pair get() = from to to + + private fun parsed(s: String): ParsedFormattedText = json.decodeFromString(ParsedFormattedText.serializer(), s) + + private fun display(ft: FormattedText): String = segmentDisplayText(ft, null, SimplexLinkMode.DESCRIPTION) + + private fun texts(p: ParsedFormattedText): List, Boolean>> = + blockTextRanges(p.markdownBlocks!!).map { it.range.pair to it.inRow } + + private fun preview(p: ParsedFormattedText): String = + p.formattedText!!.joinToString("") { ft -> (ft.format as? Format.Markup)?.token?.previewText ?: ft.text } +} diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index 3bad2e09c4..c7d6f0674f 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -316,7 +316,7 @@ Key sections: group profile, group link, member list with roles, group preferenc | `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 | +| `TextItemView.kt` | Plain text message with markdown; blocks of `markdownBlocks` drawn as a column of views by `MarkdownBlocks` (`MarkdownBlocks.kt`) | ### `views/chat/group/` diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs index dd7bbb509a..65b47d6c29 100644 --- a/apps/simplex-directory-service/src/Directory/Listing.hs +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -25,6 +25,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB import Data.Int (Int64) import Data.List (isPrefixOf) +import Data.List.NonEmpty (NonEmpty) import Data.Maybe (catMaybes, fromMaybe) import Data.Text (Text) import qualified Data.Text as T @@ -92,7 +93,9 @@ data DirectoryEntry = DirectoryEntry simplexName :: Maybe Text, groupLink :: PublicLink, shortDescr :: Maybe MarkdownList, + shortDescrBlocks :: Maybe (NonEmpty MarkdownBlock), welcomeMessage :: Maybe MarkdownList, + welcomeMessageBlocks :: Maybe (NonEmpty MarkdownBlock), imageFile :: Maybe String, activeAt :: Maybe UTCTime, createdAt :: Maybe UTCTime @@ -133,6 +136,8 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma linkLine = groupLinkLine displayName $ decodeUtf8 linkText in Just $ maybe linkLine (<> "\n\n" <> linkLine) description _ -> description + shortDescr' = toFormattedText <$> shortDescr + welcomeMessage' = toFormattedText <$> description' entry groupLink = let de = DirectoryEntry @@ -140,8 +145,10 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma displayName, simplexName = shortNameInfoStr . SimplexNameInfo NTPublicGroup <$> verifiedGroupDomain g, groupLink, - shortDescr = toFormattedText <$> shortDescr, - welcomeMessage = toFormattedText <$> description', + shortDescr = fst <$> shortDescr', + shortDescrBlocks = snd =<< shortDescr', + welcomeMessage = fst <$> welcomeMessage', + welcomeMessageBlocks = snd =<< welcomeMessage', imageFile = fst <$> imgData, activeAt = recentRoundedTime 900 now $ fromMaybe createdAt chatTs, createdAt = recentRoundedTime 86400 now createdAt @@ -194,5 +201,5 @@ generateListing dir gs = do where saveListing newDir f = LB.writeFile (newDir f) . J.encode . DirectoryListing . map fst -toFormattedText :: Text -> MarkdownList -toFormattedText t = fromMaybe [FormattedText Nothing t] $ parseMaybeMarkdownList t +toFormattedText :: Text -> (MarkdownList, Maybe (NonEmpty MarkdownBlock)) +toFormattedText t = let ParsedMarkdown ft blocks = parsedMarkdown t in (fromMaybe [FormattedText Nothing t] ft, blocks) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 76c3f43400..7d4efb0497 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -1157,10 +1157,10 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o -- "Use */filter " <> tshow gId <> " * to change spam filter level: no (disable), basic, moderate, strong.", -- "Or use */filter " <> tshow gId <> " [name[=noimage]] [captcha[=noimage]] [observer[=noimage]]* for advanced filter configuration." ] - <> ["/'filter " <> tshow gId <> " name' - enable name filter" | isNothing (rejectNames a)] - <> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge" | isNothing (passCaptcha a)] - <> ["/'filter " <> tshow gId <> " name captcha' - enable both" | isNothing (rejectNames a) || isNothing (passCaptcha a)] - <> ["/'filter " <> tshow gId <> " off' - disable filter" | isJust (rejectNames a) || isJust (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " name' - enable name filter " | isNothing (rejectNames a)] + <> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge " | isNothing (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " name captcha' - enable both " | isNothing (rejectNames a) || isNothing (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " off' - disable filter " | isJust (rejectNames a) || isJust (passCaptcha a)] showCondition = \case Nothing -> "_disabled_" Just PCAll -> "_enabled_" diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 6ca0d67ac4..7b3985e132 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -18,6 +18,7 @@ This file is generated automatically. - [BadgeServiceErrorCode](#badgeserviceerrorcode) - [BadgeStatus](#badgestatus) - [BadgeType](#badgetype) +- [BlockToken](#blocktoken) - [BlockingInfo](#blockinginfo) - [BlockingReason](#blockingreason) - [BrokerErrorType](#brokererrortype) @@ -131,8 +132,10 @@ This file is generated automatically. - [LinkContent](#linkcontent) - [LinkOwnerSig](#linkownersig) - [LinkPreview](#linkpreview) +- [ListEntry](#listentry) - [LocalBadge](#localbadge) - [LocalProfile](#localprofile) +- [MarkdownBlock](#markdownblock) - [MemberCriteria](#membercriteria) - [MsgChatLink](#msgchatlink) - [MsgContent](#msgcontent) @@ -182,6 +185,7 @@ This file is generated automatically. - [RoleGroupPreference](#rolegrouppreference) - [SMPAgentError](#smpagenterror) - [SecurityCode](#securitycode) +- [SegmentRange](#segmentrange) - [SimplePreference](#simplepreference) - [SimplexDomain](#simplexdomain) - [SimplexDomainClaim](#simplexdomainclaim) @@ -201,6 +205,7 @@ This file is generated automatically. - [SubscriptionStatus](#subscriptionstatus) - [SupportGroupPreference](#supportgrouppreference) - [SwitchPhase](#switchphase) +- [TableAlign](#tablealign) - [TimedMessagesGroupPreference](#timedmessagesgrouppreference) - [TimedMessagesPreference](#timedmessagespreference) - [TransportError](#transporterror) @@ -513,6 +518,64 @@ CredentialNotVerified: - "investor" +--- + +## BlockToken + +**Discriminated union type**: + +LineBreak: +- type: "lineBreak" + +SoftBreak: +- type: "softBreak" + +HardBreak: +- type: "hardBreak" + +ParagraphBreak: +- type: "paragraphBreak" +- lineBreaks: int + +Indent: +- type: "indent" + +HeadingMark: +- type: "headingMark" +- level: int + +HeadingClose: +- type: "headingClose" + +HeadingUnderline: +- type: "headingUnderline" +- level: int + +ListMark: +- type: "listMark" +- number: string? + +QuoteMark: +- type: "quoteMark" + +FenceMark: +- type: "fenceMark" +- language: string? + +RowMark: +- type: "rowMark" + +CellMark: +- type: "cellMark" + +DelimiterRow: +- type: "delimiterRow" +- columns: [[TableAlign](#tablealign)] + +RuleMark: +- type: "ruleMark" + + --- ## BlockingInfo @@ -1007,6 +1070,7 @@ GroupLink: - sentAt: UTCTime - content: [MsgContent](#msgcontent) - formattedText: [[FormattedText](#formattedtext)]? +- markdownBlocks: [[MarkdownBlock](#markdownblock)]? --- @@ -1505,6 +1569,7 @@ ContactConnection: - content: [CIContent](#cicontent) - mentions: {string : [CIMention](#cimention)} - formattedText: [[FormattedText](#formattedtext)]? +- markdownBlocks: [[MarkdownBlock](#markdownblock)]? - quotedItem: [CIQuote](#ciquote)? - reactions: [[CIReactionCount](#cireactioncount)] - file: [CIFile](#cifile)? @@ -2283,10 +2348,13 @@ NO_FILE: ## Format +The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. + **Discriminated union type**: Bold: - type: "bold" +- singleStar: bool Italic: - type: "italic" @@ -2340,6 +2408,13 @@ Email: Phone: - type: "phone" +Escape: +- type: "escape" + +Markup: +- type: "markup" +- token: [BlockToken](#blocktoken) + --- @@ -2877,6 +2952,16 @@ Unknown: - content: [LinkContent](#linkcontent)? +--- + +## ListEntry + +**Record type**: +- number: string? +- content: [SegmentRange](#segmentrange) +- items: [[ListEntry](#listentry)] + + --- ## LocalBadge @@ -2906,6 +2991,45 @@ Unknown: - contactDomainVerified: bool? +--- + +## MarkdownBlock + +**Discriminated union type**: + +Paragraph: +- type: "paragraph" +- content: [SegmentRange](#segmentrange) + +Heading: +- type: "heading" +- level: int +- content: [SegmentRange](#segmentrange) + +CodeBlock: +- type: "codeBlock" +- language: string? +- content: [SegmentRange](#segmentrange) + +Quote: +- type: "quote" +- content: [SegmentRange](#segmentrange) + +List: +- type: "list" +- items: [[ListEntry](#listentry)] + +Table: +- type: "table" +- columns: [[TableAlign](#tablealign)] +- header: [[SegmentRange](#segmentrange)] +- rows: [[[SegmentRange](#segmentrange)]] + +Rule: +- type: "rule" +- mark: [SegmentRange](#segmentrange) + + --- ## MemberCriteria @@ -3787,6 +3911,15 @@ A_SERVICE: - verifiedAt: UTCTime +--- + +## SegmentRange + +**Record type**: +- from: int +- to: int + + --- ## SimplePreference @@ -4408,6 +4541,17 @@ NoSub: - "completed" +--- + +## TableAlign + +**Enum type**: +- "none" +- "left" +- "center" +- "right" + + --- ## TimedMessagesGroupPreference diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 3adcaef783..ab76cc6581 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -224,6 +224,7 @@ chatTypesDocsData = (sti @BadgeServiceErrorCode, STEnum' (consSep "BSE" '_'), "", ["BSEUnknown"], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), + (sti @BlockToken, STUnion, "", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), (sti @BusinessChatInfo, STRecord, "", [], "", ""), (sti @BusinessChatType, STEnum, "BC", [], "", ""), @@ -285,7 +286,7 @@ chatTypesDocsData = (sti @FileStatus, STEnum, "FS", [], "", ""), (sti @FileTransferMeta, STRecord, "", [], "", ""), (sti @FileType, STEnum' (consLower "FT"), "", [], "", ""), - (sti @Format, STUnion, "", ["Unknown"], "", ""), + (sti @Format, STUnion, "", ["Unknown"], "", "The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars."), (sti @FormattedText, STRecord, "", [], "", ""), (sti @FullGroupPreferences, STRecord, "", [], "", ""), (sti @FullPreferences, STRecord, "", [], "", ""), @@ -321,8 +322,10 @@ chatTypesDocsData = (sti @LinkContent, STUnion, "LC", [], "", ""), (sti @LinkOwnerSig, STRecord, "", [], "", ""), (sti @LinkPreview, STRecord, "", [], "", ""), + (sti @ListEntry, STRecord, "", [], "", ""), (sti @BadgeInfo, STRecord, "", [], "", ""), (sti @LocalProfile, STRecord, "", [], "", ""), + (sti @MarkdownBlock, STUnion, "", ["UnknownBlock"], "", ""), (sti @MemberCriteria, STEnum1, "MC", [], "", ""), (sti @MsgChatLink, STUnion, "MCL", [], "", "Connection link sent in a message - only short links are allowed."), (sti @MsgContent, STUnion, "MC", [], "", ""), @@ -371,6 +374,7 @@ chatTypesDocsData = (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), (sti @RoleGroupPreference, STRecord, "", [], "", ""), (sti @SecurityCode, STRecord, "", [], "", ""), + (sti @SegmentRange, STRecord, "", [], "", ""), (sti @SimplePreference, STRecord, "", [], "", ""), (sti @SimplexDomain, STRecord, "", [], "", ""), (sti @SimplexDomainClaim, STRecord, "", [], "", ""), @@ -391,6 +395,7 @@ chatTypesDocsData = (sti @SubscriptionStatus, STUnion, "SS", [], "", ""), (sti @SupportGroupPreference, STRecord, "", [], "", ""), (sti @SwitchPhase, STEnum, "SP", [], "", ""), + (sti @TableAlign, STEnum, "TA", [], "", ""), (sti @TimedMessagesGroupPreference, STRecord, "", [], "", ""), (sti @TimedMessagesPreference, STRecord, "", [], "", ""), (sti @TransportError, STUnion, "TE", [], "", ""), @@ -457,6 +462,7 @@ deriving instance Generic BadgeRedeemError deriving instance Generic BadgeServiceErrorCode deriving instance Generic BlockingInfo deriving instance Generic BlockingReason +deriving instance Generic BlockToken deriving instance Generic BrokerErrorType deriving instance Generic BusinessChatInfo deriving instance Generic BusinessChatType @@ -561,8 +567,10 @@ deriving instance Generic JSONCIStatus deriving instance Generic LinkContent deriving instance Generic LinkOwnerSig deriving instance Generic LinkPreview +deriving instance Generic ListEntry deriving instance Generic BadgeInfo deriving instance Generic LocalProfile +deriving instance Generic MarkdownBlock deriving instance Generic MemberCriteria deriving instance Generic MsgChatLink deriving instance Generic MsgContent @@ -610,6 +618,7 @@ deriving instance Generic RemoteCtrlSessionState deriving instance Generic RemoteCtrlStopReason deriving instance Generic ReportReason deriving instance Generic SecurityCode +deriving instance Generic SegmentRange deriving instance Generic SimplexDomain deriving instance Generic SimplexDomainClaim deriving instance Generic SimplexDomainError @@ -629,6 +638,7 @@ deriving instance Generic StoreError deriving instance Generic SubscriptionStatus deriving instance Generic SupportGroupPreference deriving instance Generic SwitchPhase +deriving instance Generic TableAlign deriving instance Generic TimedMessagesGroupPreference deriving instance Generic TimedMessagesPreference deriving instance Generic TransportError diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 35711c62cf..cef7f41de5 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -343,6 +343,112 @@ export enum BadgeType { Investor = "investor", } +export type BlockToken = + | BlockToken.LineBreak + | BlockToken.SoftBreak + | BlockToken.HardBreak + | BlockToken.ParagraphBreak + | BlockToken.Indent + | BlockToken.HeadingMark + | BlockToken.HeadingClose + | BlockToken.HeadingUnderline + | BlockToken.ListMark + | BlockToken.QuoteMark + | BlockToken.FenceMark + | BlockToken.RowMark + | BlockToken.CellMark + | BlockToken.DelimiterRow + | BlockToken.RuleMark + +export namespace BlockToken { + export type Tag = + | "lineBreak" + | "softBreak" + | "hardBreak" + | "paragraphBreak" + | "indent" + | "headingMark" + | "headingClose" + | "headingUnderline" + | "listMark" + | "quoteMark" + | "fenceMark" + | "rowMark" + | "cellMark" + | "delimiterRow" + | "ruleMark" + + interface Interface { + type: Tag + } + + export interface LineBreak extends Interface { + type: "lineBreak" + } + + export interface SoftBreak extends Interface { + type: "softBreak" + } + + export interface HardBreak extends Interface { + type: "hardBreak" + } + + export interface ParagraphBreak extends Interface { + type: "paragraphBreak" + lineBreaks: number // int + } + + export interface Indent extends Interface { + type: "indent" + } + + export interface HeadingMark extends Interface { + type: "headingMark" + level: number // int + } + + export interface HeadingClose extends Interface { + type: "headingClose" + } + + export interface HeadingUnderline extends Interface { + type: "headingUnderline" + level: number // int + } + + export interface ListMark extends Interface { + type: "listMark" + number?: string + } + + export interface QuoteMark extends Interface { + type: "quoteMark" + } + + export interface FenceMark extends Interface { + type: "fenceMark" + language?: string + } + + export interface RowMark extends Interface { + type: "rowMark" + } + + export interface CellMark extends Interface { + type: "cellMark" + } + + export interface DelimiterRow extends Interface { + type: "delimiterRow" + columns: TableAlign[] + } + + export interface RuleMark extends Interface { + type: "ruleMark" + } +} + export interface BlockingInfo { reason: BlockingReason notice?: ClientNotice @@ -998,6 +1104,7 @@ export interface CIQuote { sentAt: string // ISO-8601 timestamp content: MsgContent formattedText?: FormattedText[] + markdownBlocks?: MarkdownBlock[] } export interface CIReaction { @@ -1760,6 +1867,7 @@ export interface ChatItem { content: CIContent mentions: {[key: string]: CIMention} formattedText?: FormattedText[] + markdownBlocks?: MarkdownBlock[] quotedItem?: CIQuote reactions: CIReactionCount[] file?: CIFile @@ -2569,6 +2677,7 @@ export enum FileType { Normal = "normal", Roster = "roster", } +// The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. export type Format = | Format.Bold @@ -2586,6 +2695,8 @@ export type Format = | Format.Mention | Format.Email | Format.Phone + | Format.Escape + | Format.Markup export namespace Format { export type Tag = @@ -2604,6 +2715,8 @@ export namespace Format { | "mention" | "email" | "phone" + | "escape" + | "markup" interface Interface { type: Tag @@ -2611,6 +2724,7 @@ export namespace Format { export interface Bold extends Interface { type: "bold" + singleStar: boolean } export interface Italic extends Interface { @@ -2678,6 +2792,15 @@ export namespace Format { export interface Phone extends Interface { type: "phone" } + + export interface Escape extends Interface { + type: "escape" + } + + export interface Markup extends Interface { + type: "markup" + token: BlockToken + } } export interface FormattedText { @@ -3142,6 +3265,12 @@ export interface LinkPreview { content?: LinkContent } +export interface ListEntry { + number?: string + content: SegmentRange + items: ListEntry[] +} + export interface LocalBadge { badge: BadgeInfo status: BadgeStatus @@ -3163,6 +3292,62 @@ export interface LocalProfile { contactDomainVerified?: boolean } +export type MarkdownBlock = + | MarkdownBlock.Paragraph + | MarkdownBlock.Heading + | MarkdownBlock.CodeBlock + | MarkdownBlock.Quote + | MarkdownBlock.List + | MarkdownBlock.Table + | MarkdownBlock.Rule + +export namespace MarkdownBlock { + export type Tag = "paragraph" | "heading" | "codeBlock" | "quote" | "list" | "table" | "rule" + + interface Interface { + type: Tag + } + + export interface Paragraph extends Interface { + type: "paragraph" + content: SegmentRange + } + + export interface Heading extends Interface { + type: "heading" + level: number // int + content: SegmentRange + } + + export interface CodeBlock extends Interface { + type: "codeBlock" + language?: string + content: SegmentRange + } + + export interface Quote extends Interface { + type: "quote" + content: SegmentRange + } + + export interface List extends Interface { + type: "list" + items: ListEntry[] // non-empty + } + + export interface Table extends Interface { + type: "table" + columns: TableAlign[] // non-empty + header: SegmentRange[] + rows: SegmentRange[][] + } + + export interface Rule extends Interface { + type: "rule" + mark: SegmentRange + } +} + export enum MemberCriteria { All = "all", } @@ -4216,6 +4401,11 @@ export interface SecurityCode { verifiedAt: string // ISO-8601 timestamp } +export interface SegmentRange { + from: number // int + to: number // int +} + export interface SimplePreference { allow: FeatureAllowed } @@ -5131,6 +5321,13 @@ export enum SwitchPhase { Completed = "completed", } +export enum TableAlign { + None = "none", + Left = "left", + Center = "center", + Right = "right", +} + export interface TimedMessagesGroupPreference { enable: GroupFeatureEnabled ttl?: number // int diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index f732d7faf2..373cf58d43 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -230,6 +230,77 @@ BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] BadgeType = Literal["supporter", "legend", "investor"] +class BlockToken_lineBreak(TypedDict): + type: Literal["lineBreak"] + +class BlockToken_softBreak(TypedDict): + type: Literal["softBreak"] + +class BlockToken_hardBreak(TypedDict): + type: Literal["hardBreak"] + +class BlockToken_paragraphBreak(TypedDict): + type: Literal["paragraphBreak"] + lineBreaks: int # int + +class BlockToken_indent(TypedDict): + type: Literal["indent"] + +class BlockToken_headingMark(TypedDict): + type: Literal["headingMark"] + level: int # int + +class BlockToken_headingClose(TypedDict): + type: Literal["headingClose"] + +class BlockToken_headingUnderline(TypedDict): + type: Literal["headingUnderline"] + level: int # int + +class BlockToken_listMark(TypedDict): + type: Literal["listMark"] + number: NotRequired[str] + +class BlockToken_quoteMark(TypedDict): + type: Literal["quoteMark"] + +class BlockToken_fenceMark(TypedDict): + type: Literal["fenceMark"] + language: NotRequired[str] + +class BlockToken_rowMark(TypedDict): + type: Literal["rowMark"] + +class BlockToken_cellMark(TypedDict): + type: Literal["cellMark"] + +class BlockToken_delimiterRow(TypedDict): + type: Literal["delimiterRow"] + columns: list["TableAlign"] + +class BlockToken_ruleMark(TypedDict): + type: Literal["ruleMark"] + +BlockToken = ( + BlockToken_lineBreak + | BlockToken_softBreak + | BlockToken_hardBreak + | BlockToken_paragraphBreak + | BlockToken_indent + | BlockToken_headingMark + | BlockToken_headingClose + | BlockToken_headingUnderline + | BlockToken_listMark + | BlockToken_quoteMark + | BlockToken_fenceMark + | BlockToken_rowMark + | BlockToken_cellMark + | BlockToken_delimiterRow + | BlockToken_ruleMark +) + +BlockToken_Tag = Literal["lineBreak", "softBreak", "hardBreak", "paragraphBreak", "indent", "headingMark", "headingClose", "headingUnderline", "listMark", "quoteMark", "fenceMark", "rowMark", "cellMark", "delimiterRow", "ruleMark"] + class BlockingInfo(TypedDict): reason: "BlockingReason" notice: NotRequired["ClientNotice"] @@ -692,6 +763,7 @@ class CIQuote(TypedDict): sentAt: str # ISO-8601 timestamp content: "MsgContent" formattedText: NotRequired[list["FormattedText"]] + markdownBlocks: NotRequired[list["MarkdownBlock"]] # non-empty class CIReaction(TypedDict): chatDir: "CIDirection" @@ -1224,6 +1296,7 @@ class ChatItem(TypedDict): content: "CIContent" mentions: dict[str, "CIMention"] formattedText: NotRequired[list["FormattedText"]] + markdownBlocks: NotRequired[list["MarkdownBlock"]] # non-empty quotedItem: NotRequired["CIQuote"] reactions: list["CIReactionCount"] file: NotRequired["CIFile"] @@ -1795,8 +1868,11 @@ class FileTransferMeta(TypedDict): FileType = Literal["normal", "roster"] +# The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. + class Format_bold(TypedDict): type: Literal["bold"] + singleStar: bool class Format_italic(TypedDict): type: Literal["italic"] @@ -1850,6 +1926,13 @@ class Format_email(TypedDict): class Format_phone(TypedDict): type: Literal["phone"] +class Format_escape(TypedDict): + type: Literal["escape"] + +class Format_markup(TypedDict): + type: Literal["markup"] + token: "BlockToken" + Format = ( Format_bold | Format_italic @@ -1866,9 +1949,11 @@ Format = ( | Format_mention | Format_email | Format_phone + | Format_escape + | Format_markup ) -Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone"] +Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone", "escape", "markup"] class FormattedText(TypedDict): format: NotRequired["Format"] @@ -2191,6 +2276,11 @@ class LinkPreview(TypedDict): image: str content: NotRequired["LinkContent"] +class ListEntry(TypedDict): + number: NotRequired[str] + content: "SegmentRange" + items: list["ListEntry"] + class LocalBadge(TypedDict): badge: "BadgeInfo" status: "BadgeStatus" @@ -2210,6 +2300,50 @@ class LocalProfile(TypedDict): contactDomain: NotRequired["SimplexDomainClaim"] contactDomainVerified: NotRequired[bool] +class MarkdownBlock_paragraph(TypedDict): + type: Literal["paragraph"] + content: "SegmentRange" + +class MarkdownBlock_heading(TypedDict): + type: Literal["heading"] + level: int # int + content: "SegmentRange" + +class MarkdownBlock_codeBlock(TypedDict): + type: Literal["codeBlock"] + language: NotRequired[str] + content: "SegmentRange" + +class MarkdownBlock_quote(TypedDict): + type: Literal["quote"] + content: "SegmentRange" + +class MarkdownBlock_list(TypedDict): + type: Literal["list"] + items: list["ListEntry"] # non-empty + +class MarkdownBlock_table(TypedDict): + type: Literal["table"] + columns: list["TableAlign"] # non-empty + header: list["SegmentRange"] + rows: list[list["SegmentRange"]] + +class MarkdownBlock_rule(TypedDict): + type: Literal["rule"] + mark: "SegmentRange" + +MarkdownBlock = ( + MarkdownBlock_paragraph + | MarkdownBlock_heading + | MarkdownBlock_codeBlock + | MarkdownBlock_quote + | MarkdownBlock_list + | MarkdownBlock_table + | MarkdownBlock_rule +) + +MarkdownBlock_Tag = Literal["paragraph", "heading", "codeBlock", "quote", "list", "table", "rule"] + MemberCriteria = Literal["all"] # Connection link sent in a message - only short links are allowed. @@ -2937,6 +3071,10 @@ class SecurityCode(TypedDict): securityCode: str verifiedAt: str # ISO-8601 timestamp +class SegmentRange(TypedDict): + from: int # int + to: int # int + class SimplePreference(TypedDict): allow: "FeatureAllowed" @@ -3573,6 +3711,8 @@ class SupportGroupPreference(TypedDict): SwitchPhase = Literal["started", "confirmed", "secured", "completed"] +TableAlign = Literal["none", "left", "center", "right"] + class TimedMessagesGroupPreference(TypedDict): enable: "GroupFeatureEnabled" ttl: NotRequired[int] # int diff --git a/plans/2026-10-13-block-markdown-rendering.md b/plans/2026-10-13-block-markdown-rendering.md new file mode 100644 index 0000000000..fd5a3be739 --- /dev/null +++ b/plans/2026-10-13-block-markdown-rendering.md @@ -0,0 +1,415 @@ +# Block Markdown: rendering in the apps and on the web + +Date: 2026-10-13. Revised: 2026-10-16, for master. Revised: 2026-10-17, drawing from markdownBlocks. Revised: 2026-10-18, drawing by index ranges (S5). Revised: 2026-10-18, a column of block views (approved mockup https://claude.ai/artifact/7Twe2qJcdULBbcqhSPuh6k, v3). + +Where a whole message is shown, the apps draw `formattedText` with `markdownBlocks` (`2026-10-13-block-markdown.md`, sections 2 and 4) as a column of block views; each text view is the segment loop of master over the index range of one block, list entry or table cell (section 3). Previews, quotes, the compose context, the profile teaser and notifications stay one text. A message without blocks is drawn as now. On the web, HTML blocks are drawn by the page script. + +References are to master at 382dc7480. + +``` +K/ apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ +I/ apps/ios/Shared/Views/ +S/ apps/ios/SimpleXChat/ +W/ website/src/js/ +``` + +## 1. Scope + +``` +blocks drawn every list drawn by MarkdownText (K/views/chat/item/TextItemView.kt:125) + every list drawn by messageText (I/Chat/ChatItem/MsgContentView.swift:396) + desktop selection (K/views/chat/TextSelection.kt:264-305) + copied text of a selection: as drawn, by the same block drawing + the Copy action of an item: its source text, as now +tokens by kind notification texts (K/platform/NtfManager.kt:134-148, S/Notifications.swift:200-210) +token form compose state: parsedMessage, links, mentions, link sanitizing, as now + K/views/chat/ComposeView.kt:111, 130, 1024-1070 + I/Chat/ComposeMessage/ComposeView.swift:560, 1240, 1990 +HTML blocks renderMarkdown (W/simplex-lib.jsc:57-128) +``` + +- Per text view, as in one text now: + - taps + - long presses + - secrets + - mentions + - commands +- Per message: + - the typing indicator and the meta reserve: at the end of the last view when it is text, otherwise in a line below + - the meta reserve after text: a line break when the text direction differs from the bubble's, as now; spaces in the line below (iOS `reserveSpaceForMeta(mt, newLine: false)`) + - text direction: the column in the text direction, text aligned to its start +- Code blocks and tables scroll sideways; a drag that starts in them does not swipe the item. + +## 2. Types + +Kotlin, `K/model/ChatModel.kt`: + +``` +Format (:5177) @Serializable @SerialName("bold") class Bold(val singleStar: Boolean = false): Format() (:5178) + @Serializable @SerialName("escape") class Escape: Format() + @Serializable @SerialName("markup") class Markup(val token: BlockToken): Format() + style of Escape and Markup: SpanStyle() +BlockToken @Serializable sealed class, next to Format + @SerialName("lineBreak") class LineBreak + @SerialName("softBreak") class SoftBreak + @SerialName("hardBreak") class HardBreak + @SerialName("paragraphBreak") class ParagraphBreak(val lineBreaks: Int) + @SerialName("indent") class Indent + @SerialName("headingMark") class HeadingMark(val level: Int) + @SerialName("headingClose") class HeadingClose + @SerialName("headingUnderline") class HeadingUnderline(val level: Int) + @SerialName("listMark") class ListMark(val number: String? = null) + @SerialName("quoteMark") class QuoteMark + @SerialName("fenceMark") class FenceMark(val language: String? = null) + @SerialName("rowMark") class RowMark + @SerialName("cellMark") class CellMark + @SerialName("delimiterRow") class DelimiterRow(val columns: List) + @SerialName("ruleMark") class RuleMark +MarkdownBlock @Serializable sealed class + @SerialName("paragraph") class Paragraph(val content: SegmentRange) + @SerialName("heading") class Heading(val level: Int, val content: SegmentRange) + @SerialName("codeBlock") class CodeBlock(val language: String? = null, val content: SegmentRange) + @SerialName("quote") class Quote(val content: SegmentRange) + @SerialName("list") class ItemList(val items: List) + @SerialName("table") class Table(val columns: List, val header: List, val rows: List>) + @SerialName("rule") class Rule(val mark: SegmentRange) +ListEntry @Serializable class ListEntry(val number: String? = null, val content: SegmentRange, val items: List) +SegmentRange @Serializable class SegmentRange(val from: Int, val to: Int) +TableAlign @Serializable enum class: none, left, center, right +BlockToken.drawnText the text of the token in a block view (section 3) +BlockToken.previewText the text of the token in one text (section 3) +BlockTextRange class BlockTextRange(val range: SegmentRange, val inRow: Boolean) +MarkdownBlock.textRanges the ranges of the text views of a block, in source order + Paragraph, Heading, CodeBlock, Quote: content + ItemList: the contents of the entries, a parent before its children + Table: the cells of the header, then of each row; inRow for each cell after the first of a row + Rule: none +listEntries (items, level) -> List>: the entries with their levels, a parent before its children +blockTextRanges (blocks) -> the textRanges of all blocks +headingSegments (size, blocks) -> BooleanArray: the segments in heading contents +segmentDisplayText (ft: FormattedText, mentions: Map?, linkMode: SimplexLinkMode) -> String + the body of itemSegmentDisplayText (K/views/chat/item/TextItemView.kt:88-108), with mentions instead of ci + Escape and Markup: the text drawn for the token in a block view + replaces itemSegmentDisplayText +itemDisplay (ci: ChatItem, linkMode: SimplexLinkMode) -> ItemDisplay?, null without formattedText + parts: per segment the displayed and the copied text (Markup: its drawn text both); + between text views a part "\t" (between cells of a row) or "\n" + bases: per text view the offset of its first character in the selection offsets of the item; 0 for the first + without markdownBlocks: the segments, bases [0] +MarkdownBlocks composable: the column of block views (section 3) +``` + +- Kotlin name `ItemList`: `List` is `kotlin.collections.List` in the scope of `MarkdownBlock`; JSON tag "list". + +Kotlin records, field `markdownBlocks: List? = null` after `formattedText`: + +``` +ChatItem K/model/ChatModel.kt:3409 +CIQuote K/model/ChatModel.kt:4415 +ChatItemVersion K/model/ChatModel.kt:5716 +ParsedFormattedText K/views/helpers/TextEditor.kt:134-136 +``` + +iOS, `S/ChatTypes.swift`: + +``` +Format (:5681) case bold(singleStar: Bool) + case escape + case markup(token: BlockToken) +BlockToken enum: Decodable, Equatable, Hashable + cases as the Kotlin classes, associated values as their fields +MarkdownBlock enum: Decodable, Equatable, Hashable + cases as the Kotlin classes, list as `list` +ListEntry struct: Decodable, Equatable, Hashable +SegmentRange struct: Decodable, Equatable, Hashable +TableAlign enum: String, Decodable, Hashable +drawnText, previewText, BlockTextRange, textRanges, listEntries, blockTextRanges, headingSegments: as Kotlin +MarkdownBlocksView SwiftUI view, generic over the block text view, in I/Chat/ChatItem/MsgContentView.swift (section 3) +MarkdownView SwiftUI view: one text, or MarkdownBlocksView when blocks are present +``` + +- The `case .bold:` pattern (I/Chat/ChatItem/MsgContentView.swift:450) binds `singleStar`. + +iOS records, field `markdownBlocks: [MarkdownBlock]?` after `formattedText`: + +``` +ChatItem S/ChatTypes.swift:3810 +CIQuote S/ChatTypes.swift:4861 +ChatItemVersion S/ChatTypes.swift:6400 +ParsedMarkdown S/API.swift:179-181 +``` + +- `FormattedText` is unchanged. +- With an unknown block or token tag, the item is invalid, as with an unknown `Format` tag. + +## 3. Drawing + +Segment loop: + +``` +segment loop master's loop over formattedText + Kotlin: MarkdownText (K/views/chat/item/TextItemView.kt) + Swift: messageText (I/Chat/ChatItem/MsgContentView.swift), the loop in a nested function appendSegments +range a block view: the range of its block, entry or cell; otherwise all indices +font Kotlin: the style passed by the column + Swift: parameter baseFont; the fonts of formats and links derived from it +Bold singleStar italic, when markdownBlocks is present +HyperLink linkUri "mailto:" (e-mail autolink): opened as an e-mail link + Kotlin: annotation OTHER_URL; Swift: without webLinkAttrKey + FormattedText.linkUri: null for it, so no link preview +``` + +Modes of the segment loop (Kotlin `MarkdownText`, Swift `messageText`): + +``` +block view parameter range (a text view of the column) + tokens by drawnText: SoftBreak " ", LineBreak and HardBreak "\n", ParagraphBreak n × "\n", other markup nothing +one text markdownBlocks present, no range (previews, quotes, compose context, profile teaser) + tokens by previewText: SoftBreak, HardBreak and CellMark " "; LineBreak and ParagraphBreak "\n"; + ListMark the number or "•" and " "; other markup nothing + segments in heading contents (headingSegments) bold +no blocks markup drawn as its text; Escape nothing +``` + +The column (Kotlin `MarkdownBlocks`, `blockLayout = true` of `MarkdownText`; Swift `MarkdownBlocksView`, `blockLayout` of `MsgContentView`, and `MarkdownView`): + +``` +order blocks in source order; text view index i = the i-th of blockTextRanges +spacing a ParagraphBreak between two blocks: half a line; otherwise none +Paragraph text view, body font +Heading text view, bold; size × 1.5 (level 1), 1.3 (2), 1.15 (3), 1 (4-6); line height scaled alike +List per entry (listEntries): a row of the marker ("•" or the number) and the text view; indent 20 per level +Quote a 4 wide bar (text colour at 16%), 10 gap, the text view at 70-72% opacity +CodeBlock card in the bubble's quote colour, corners 12 × the bubble roundness setting, the bubble's full width + the text view: monospace, size × 0.875, no wrapping, scrolls sideways + copy icon at the top right: on hover (desktop) or after a tap (mobile); copies the drawn text of the range +Table rounded frame (12 × roundness, 1 line), scrolls sideways + columns as wide as their widest cell; header row bold on the quote colour; alternate body rows tinted + lines between rows and columns; a cell aligned by its column + a missing cell at a row end: an empty cell (GFM 0.29 example 204) + Kotlin: a Layout measuring cells by max intrinsic width, composing only the present cells + iOS 16+: Grid, columns × rows at most 1000 (maxGridCells); otherwise, and on iOS 15: the table as typed in a code card +Rule 1 line, 6 above and below +first view gets the sender and the report prefix +last view gets the meta reserve and the typing indicator when it is text; otherwise a line with them below the column +ranges limited to the length of fts +``` + +- Quote colour: `sentQuote` in a sent bubble, `receivedQuote` otherwise (Kotlin `AppColors`, Swift `theme.appColors`). +- Swipe to reply: Kotlin: the horizontal scroll consumes the drag before `swipeable` of the item. iOS: scrolling blocks report their frames (`SwipeExclusionFrames`) in the item's coordinate space; the item's drag gesture ignores drags that start in them. +- iOS: a horizontal scroll only when the content is wider than the bubble (`ViewThatFits` on iOS 16+). + +Secrets (Kotlin, as iOS `hiddenSecretsView`): a hidden secret is drawn transparent, and its text path is painted with the tile `vertical_logo` (8 dp, rotated 45°, saturation 0.65, alpha 0.35), added to `moko-resources/images` from the iOS asset. + +Desktop selection (K/views/chat/TextSelection.kt): + +``` +setupItemSelection (selectionManager, selectionIndex, isLive, bases) +text views each registers its bounds and layout with its base (ItemSelection.text(index)) +pointer offset the view under the pointer, or the nearest; base + its local offset +highlight per view, the selected offsets of the item within the view, shifted by its base +copy, snap itemDisplay(ci).parts: copied text as before, with "\t" and "\n" between views +``` + +Notifications, `hideSecrets` (K/platform/NtfManager.kt, S/Notifications.swift): Secret "...", markup by previewText, Escape nothing, other segments their text. + +## 4. Call sites + +Kotlin: + +``` +MarkdownText (K/views/chat/item/TextItemView.kt) parameters markdownBlocks, blockLayout, range, softWrap, cardColor, selection, textIndex, parentDirection (the bubble's direction, for the meta reserve of block views); section 3 +CIMarkdownText (K/views/chat/item/FramedItemView.kt:445) ci.markdownBlocks; column; quote colour by direction; selection with itemDisplay bases +ciQuotedMsgTextView (K/views/chat/item/FramedItemView.kt:61) qi.markdownBlocks; one text +item preview (K/views/chatlist/ChatPreviewView.kt:261-265, 277) null where formattedText is null, otherwise ci.markdownBlocks; one text +TextBubble (K/views/chat/ChatItemInfoView.kt:55-57) the markdownBlocks of the item or the version; column +ContextItemView (K/views/chat/ContextItemView.kt:59) contextItem.markdownBlocks; one text +ProfileDescriptionText (K/views/chat/ChatInfoView.kt:794-795, 804-805) blocks of parseToMarkdownText; one text +showFullProfileDescription (K/views/chat/item/TextItemView.kt:56-58) blocks of parseToMarkdownText; column +WelcomeMessageView (K/views/chat/group/WelcomeMessageView.kt:149-151) blocks of parseToMarkdownText; column +desktop selection (K/views/chat/TextSelection.kt:264-305) section 3; itemDisplay +notifications (K/platform/NtfManager.kt:134-148) section 3 +parseToMarkdownText (K/views/helpers/TextEditor.kt) new: (text: String) -> ParsedFormattedText? +parseToMarkdown (K/views/helpers/TextEditor.kt:138-146) the formattedText of parseToMarkdownText +``` + +iOS: + +``` +messageText (I/Chat/ChatItem/MsgContentView.swift) parameters markdownBlocks, range, baseFont, secretStart; section 3 +MsgContentView (I/Chat/ChatItem/MsgContentView.swift) vars markdownBlocks, blockLayout, cardColor; MarkdownBlocksView rightToLeft from its rightToLeft, MarkdownView from isRightToLeft(text) +item content (I/Chat/ChatItem/MsgContentView.swift:624) chatItem.markdownBlocks +quote (I/Chat/ChatItem/FramedItemView.swift:337) qi.markdownBlocks; one text +FramedItemView (I/Chat/ChatItem/FramedItemView.swift:366) the markdownBlocks of the item; column; quote colour by direction +chatItemPreviewText (I/ChatList/ChatPreviewView.swift:305-316, 302) nil where formattedText is nil, otherwise ci.markdownBlocks; one text +TextBubble (I/Chat/ChatItemInfoView.swift:298-319) the markdownBlocks of the item or the version; MarkdownView +ContextItemView (I/Chat/ComposeMessage/ContextItemView.swift:74) contextItem.markdownBlocks; one text +markdownText (I/Chat/ChatItem/MsgContentView.swift:331-352) blocks of parseSimpleXMarkdownText; one text +FullProfileDescriptionView, GroupWelcomeView textPreview MarkdownView with blocks of parseSimpleXMarkdownText +item swipe (I/Chat/ChatView.swift chatItemWithMenu) ignores drags starting in SwipeExclusionFrames +notifications (S/Notifications.swift:200-210) section 3 +parseSimpleXMarkdownText (S/API.swift) new: (_ s: String) -> ParsedMarkdown? +parseSimpleXMarkdown (S/API.swift:151-162) the formattedText of parseSimpleXMarkdownText +``` + +Without blocks: + +``` +K/views/chatlist/ChatPreviewView.kt:233 draft: formattedText null +K/views/chat/ChatInfoView.kt:811-813 teaser with Format.Modal appended +I/Chat/ChatItem/MsgContentView.swift:380-383 teaser with Format.modal appended +``` + +Unchanged: + +``` +K/views/chat/item/FramedItemView.kt:349 short emoji test: formattedText == null +I/Chat/ChatItem/FramedItemView.swift:145 short emoji test: formattedText == nil +K/views/chat/ComposeView.kt:130 edit of a sent item: the token form, for links and mentions +K/views/usersettings/MarkdownHelpView.kt inline formats +I/UserSettings/MarkdownHelp.swift inline formats +``` + +## 5. Web + +`renderMarkdown(fts, blocks)` (`W/simplex-lib.jsc:57-128`): + +``` +blocks absent as now; escape: nothing +blocks invalid as now; escape: nothing + invalid: not an array + an unknown type + a range not of integers with 0 <= from <= to <= fts.length + a range starting before the end of the previous range, in source order + a list deeper than 7 levels +blocks in source order, each block one element below +range each segment drawn as now + SoftBreak: " " + LineBreak, HardBreak:
+ other markup, escape: nothing + Bold, singleStar: +gaps a ParagraphBreak between two blocks:
; otherwise nothing +Paragraph
: the range +Heading
: the range; N: the level 1-3, otherwise 4 +CodeBlock
: the range, then a copy button (class md-copy, icons md-icon-copy and md-icon-done)
+Quote            
: the range +List each entry, in source order:
: "•" or the number; : the range + N: 1.25 × the level below the top, at most 6 levels +Table
: the header row in ' + cells.map((r, c) => cell(tag, c, r)).join('') + Array.from({ length: Math.max(0, width - cells.length) }, (_, k) => cell(tag, cells.length + k, null)).join('') + ''; + gap(b.header.length > 0 ? b.header[0].from : pos); + html += '
, the body rows in + each cell: style="text-align: A;"; A: the column alignment, none as left, any other value as left + rows padded with empty cells to the column count when columns × rows <= 1000 (MAX_TABLE_CELLS) +Rule
+number escaped text +copy handleCodeClick: a click on the button copies the text of the
 (innerText); a click elsewhere in the card toggles the class tapped
+```
+
+Styles, as the apps (section 3), in each page: `.simplex-preview-text .md-*` in W/channel-preview.jsc (colours from its `--sp-*` variables, card `--sp-quote`, corners 10px for its 18px bubbles), `#directory .entry .md-*` in website/src/directory.html (light and `.dark` values, corners 4px as its entries). The flex items around the text get `min-width: 0` (`.simplex-preview-msg-col`, `#directory .entry .text-container`), so that wide blocks scroll inside the message. `copyToClipboard` moved from W/channel-preview.jsc to W/simplex-lib.jsc.
+
+Call sites:
+
+```
+W/channel-preview.jsc:880-887       the welcome message object: markdownBlocks: data.welcomeMessageBlocks
+W/channel-preview.jsc:990           renderMarkdown(shortDescr, data.shortDescriptionBlocks)
+W/channel-preview.jsc:1522          renderMarkdown(msg.formattedText, msg.markdownBlocks)
+W/directory.jsc:232                 shortDescrBlocks, welcomeMessageBlocks from the entry
+W/directory.jsc:251-255             renderMarkdown with the blocks of each list
+W/directory.jsc:254, 260            

kept: set by innerHTML, the block elements become children of the

+``` + +- The meta span after a message with a block at its end (`W/channel-preview.jsc:1522`) is drawn on its own line. +- Click handlers: W/channel-preview.jsc `setupSecretToggles` calls `handleCodeClick`; W/directory.jsc `initDirectory` adds it on `#directory`. + +## 6. Docs + +``` +apps/multiplatform/spec/client/chat-view.md TextItemView.kt: blocks of markdownBlocks drawn as a column (MarkdownBlocks.kt) +apps/ios/spec/client/chat-view.md MsgContentView: the same (MarkdownBlocksView) +``` + +## 7. Tests + +`apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt`: + +``` +decoding each block token in the JSON of the core + each block, a list with nested entries + each TableAlign value + with and without language + with and without number + Bold with and without singleStar; Escape +text ranges blockTextRanges for each block kind: order, inRow of table cells +list entries listEntries: order and levels +headings headingSegments, also with a shortened list +token texts drawnText and previewText of each token kind; Escape; one-text previews of a heading, paragraph and list +selection itemDisplay: displayed and copied texts and bases of a quote and a table; Escape without blocks +``` + +Checks on each platform of the apps: + +1. A sent message with each block kind: drawn by section 3. +2. A received message with each block kind: drawn by section 3. +3. A text without blocks: drawn as before. +4. Inline parts in each block with content: actions as in a paragraph. + +``` +inline parts a link + a mention + a secret + a command +blocks a heading + a list item + a quote + a table cell +``` + +5. A message with blocks in a quote, the chat list preview, the compose context, item info and a notification. +6. Edit of a sent message with blocks: the compose field has the source text. +7. Desktop: selection inside a block, across blocks, across items, and its copy. +8. Live message with an open code fence. +9. Live message with a growing table. +10. Chat link item with text ending with a heading: no empty line at the end of the text. +11. A profile description and a welcome message with a list. +12. iOS: the cases of `MarkdownBlocksTest.kt` drawn equal to Android. + +Web: + +13. A channel page with a message of each block kind. +14. A directory entry with a welcome message with a list. +15. Relay JSON with hostile values: `number` with markup, `columns` with markup, a range out of bounds, a range not of integers, an unknown block type. + +Not automated: + +``` +iOS the Swift views: check 12 only +web script checks 13-15 only +``` + +## Interfaces + +``` +app types Format: Markup, Escape, Bold singleStar + BlockToken + MarkdownBlock + ListEntry + SegmentRange + TableAlign + BlockTextRange +records markdownBlocks: ChatItem, CIQuote, ChatItemVersion, ParsedFormattedText (Kotlin), ParsedMarkdown (Swift) +functions textRanges, blockTextRanges, listEntries, headingSegments + segmentDisplayText, itemDisplay (Kotlin) + parseToMarkdownText (Kotlin), parseSimpleXMarkdownText (Swift) + the segment loop: range; Swift: baseFont, secretStart, nested function appendSegments +views MarkdownBlocks (Kotlin), MarkdownBlocksView and MarkdownView (Swift) +selection ItemSelection per text view (Kotlin) +resources vertical_logo@1x, @2x, @3x.png (Kotlin, from the iOS asset) +files MarkdownBlocksTest.kt, MarkdownBlocks.kt +``` + +## Depends on + +``` +block Markdown plan, "Types" constructors, fields, JSON tags +block Markdown plan, "Pass 2: blocks" ranges +block Markdown plan, "Records and parse sites" fields +release both plans together +``` diff --git a/plans/2026-10-13-block-markdown.md b/plans/2026-10-13-block-markdown.md new file mode 100644 index 0000000000..d5a39caf36 --- /dev/null +++ b/plans/2026-10-13-block-markdown.md @@ -0,0 +1,867 @@ +# Block Markdown: core + +Date: 2026-10-13. Revised: 2026-10-16, for master. Revised: 2026-10-17, two passes over the whole text. Revised: 2026-10-18, CommonMark syntax (S4, S6), soft breaks (S5), block lines then inline content. + +Each message text is parsed in two passes. In pass 1, the lines of the text are grouped into blocks. The content of the lines of a block is joined with "\n" and parsed by the inline parser of master; each "\n" is then replaced by the break token and the syntax tokens of the next line. Pass 2 divides the tokens at the breaks between blocks and builds the block structure. `formattedText` is the joined token list; the text of each token is its source. `markdownBlocks` is the block structure: index ranges into `formattedText`. + +References are to master at 382dc7480. Haskell paths without a directory are in `src/Simplex/Chat/`. + +## 1. Syntax + +### Lines + +``` +line break "\n", with an optional "\r" before it +final one line break at the text end is removed before pass 1, as by T.lines (Markdown.hs:162) +line the text between two line breaks, or between a line break and the text start or end +hard break two or more spaces, or an odd number of backslashes, before a line break, when the next line is a continuation of the block (section 3) + CommonMark 0.31.2 section 6.7, examples 633, 634 +soft break a line break before a line that is a continuation of a paragraph, a list entry or a quote, without a hard break + CommonMark 0.31.2, section "Soft line breaks" +indentation spaces and tabs at a line start + tab stops every 4 columns +blank line white space only +``` + +- Drawing of line breaks in messages with blocks: rendering plan, section 3. + +### Line kinds + +Tested in this order at a line start, at any indentation: + +``` +code line a line inside a code block, until its closing fence +table start a line with "|", when the next line is a delimiter row with the same number of cells +fence 3 or more "`", then an info string without "`" + or 3 or more "~", then an info string + when a line follows it; a fence line at the text end is text +heading 1 to 6 "#", a space, then text with a character other than white space + closing sequence: a space or a tab, one or more "#", then white space only, at the line end +underline "=" only, or "-" only, then white space only, when the open block is a paragraph + CommonMark 0.31.2 section 4.3 (setext heading underline) +rule 3 or more of one of "-", "*", "_", with spaces and tabs between and after them +list item a marker, a space, then text with a character other than white space + marker: "-", "*", "+", or 1 to 9 digits and "." or ")" +quote ">", then text with a character other than white space + or blank quote lines before such a line + after a paragraph line: such a line, or one blank quote line before it +table row a line with "|", while a table is open +blank white space only +paragraph any other line +``` + +- Delimiter row: a line with "|" where every cell, trimmed, is `:?-+:?`. +- The line after a closing fence is tested anew. + +### Blocks + +``` +CodeBlock a fence line, the code lines after it, and the closing fence line + closing fence: at least as many of the character of the opening fence, then white space only, at any indentation + without a closing fence the block is closed at the end of the text + in a code line the white space up to the column of the opening fence is syntax + a tab that crosses that column is syntax + language: the first word of the info string + language is absent for an empty info string +Heading one heading line + level: the number of "#" + the closing sequence is syntax + or: the lines of a paragraph and the underline after them + level: 1 for "=", 2 for "-" +Rule one rule line +List consecutive list items, blank lines between them included +ListEntry a list item line and its continuation lines (section 3) + content: the text after the marker and the space, then the continuation lines without their indentation + number: the digits and the delimiter, as written + number is absent for "-", "*" and "+" +Quote consecutive quote lines, and the paragraph lines that are continuations of them (section 3) + CommonMark 0.31.2 section 5.1, example 247 + text of a quote line: after ">" and one optional space or tab + a text of white space only is syntax + the lines with texts of white space only at its start and at its end are outside its content +Table a table start line, its delimiter row, and the table rows after them + columns: one per delimiter cell + "-" below: one or more "-" + ":-:" TACenter + "-:" TARight + ":-" TALeft + "-" TANone + header: the cells of the table start line + rows: the cells of each table row + cells are divided at each "|" + the "|" at the line start and at the line end are optional + in a row longer than the header, the last cell is the text from the start of its column to the end of the row, without a "|" at the line end + the white space around the text of a cell is syntax +Paragraph consecutive paragraph lines (section 3) + a paragraph line is text as written, indentation included, as now +``` + +List nesting: + +``` +parent the nearest entry before it with a smaller column, in the same List +depth at most 7 (maxListDepth) + an entry below depth 7: added at depth 7 +blank line the List continues +other block the List ends +``` + +Content: + +``` +inline the lines of a paragraph, of a list entry, of a quote: one text (section 3, inlineLines) + the text of a heading line + each cell +code each code line after its syntax: one segment without format +``` + +### Inline formats across line breaks + +Decided 2026-10-17. + +``` +formats Bold, Italic, StrikeThrough, Snippet, Secret, Small, Colored +across a format is continued into the next line when that line is a continuation of the block (section 3) + one segment of the format per line + the line break and the syntax of the next line between them + never across a blank line + "*", "_", "~": across lines when the content does not start or end with white space; otherwise as now, within the line +within headings and cells: within one line + links, SimpleX links, names, mentions, commands, e-mails, phones: within one line +``` + +Bold, decided 2026-10-18: + +``` +*x* Bold, singleStar = True +**x** Bold, singleStar = False +``` + +- `*x*` is drawn as italic in messages with blocks (rendering plan, section 3). + +### Inline syntax of GFM + +Decided 2026-10-18 (S4, S6). In all messages, with and without blocks: + +``` +~~x~~ StrikeThrough GFM example 491 +__x__ Bold, singleStar = False GFM example 391 +`x` Snippet CommonMark section 6.1, GFM example 339 +`` x ` y `` a run of backticks, the content, a run of the same length + one space removed at each end when the content starts and ends with a space and is not spaces only + a space at one end only: kept (CommonMark example 332); spaces only: kept (example 334) + master's check of spaces at the ends (Markdown.hs:231-235): not applied to Snippet + as [x](x) (sowLinkP, Markdown.hs:281-293), its text "" GFM example 603 + x an e-mail address: HyperLink {showText = Just x, linkUri = "mailto:" <> x} +\c c an ASCII punctuation character: Escape "\", then c without format CommonMark examples 12, 14 + not in code blocks and snippets + "\" before a line break: a hard break (section 1, Lines) +``` + +### Text, as now + +``` +raw HTML +images ![alt](uri) +indented code +a list marker inside a quote +a fence inside a quote +a quote marker inside a quote +nested inline formats +``` + +### Incomplete syntax in a live message + +``` +fence line at the text end paragraph line + a code block once a line follows +open code fence code to the end of the text +table header row paragraph line + a table once the delimiter row is complete +rule characters at the text end rule + text once another character is added to the line +block marker before its text text + a block once text is added +open format at the text end text + a format once it is closed +``` + +- Each version of a live message is parsed from its whole text (`Store/Messages.hs:2583`). + +## 2. Types + +`Markdown.hs`: + +```haskell +data Format + = Bold {singleStar :: BoolDef} + | ... -- present constructors, unchanged + | Phone + | Escape + | Markup {token :: BlockToken} + | Unknown {json :: J.Value} + +data BlockToken + = LineBreak + | SoftBreak + | HardBreak + | ParagraphBreak {lineBreaks :: Int} + | Indent + | HeadingMark {level :: Int} + | HeadingClose + | HeadingUnderline {level :: Int} + | ListMark {number :: Maybe Text} + | QuoteMark + | FenceMark {language :: Maybe Text} + | RowMark + | CellMark + | DelimiterRow {columns :: [TableAlign]} + | RuleMark + deriving (Eq, Show) + +data MarkdownBlock + = Paragraph {content :: SegmentRange} + | Heading {level :: Int, content :: SegmentRange} + | CodeBlock {language :: Maybe Text, content :: SegmentRange} + | Quote {content :: SegmentRange} + | List {items :: NonEmpty ListEntry} + | Table {columns :: NonEmpty TableAlign, header :: [SegmentRange], rows :: [[SegmentRange]]} + | Rule {mark :: SegmentRange} + | UnknownBlock {json :: J.Value} + deriving (Eq, Show) + +data ListEntry = ListEntry {number :: Maybe Text, content :: SegmentRange, items :: [ListEntry]} + deriving (Eq, Show) + +data SegmentRange = SegmentRange {from :: Int, to :: Int} + deriving (Eq, Show) + +data TableAlign = TANone | TALeft | TACenter | TARight + deriving (Eq, Show) + +data ParsedMarkdown = ParsedMarkdown + { formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock) + } +``` + +- `FormattedText` is unchanged. +- `MarkdownList` is unchanged: `[FormattedText]`. +- Markup token: a segment with format `Markup`. +- `Escape`: the backslash of a backslash escape; text `"\"`. +- `markdownText` (`Markdown.hs:472-491`): + - a markup token, `Escape`: its text + - `Bold`: `*x*` when `singleStar`, `**x**` otherwise (`Markdown.hs:476`) + - `Snippet`: delimited by a backtick run longer than any backtick run in its text, with a space inside each delimiter when the text starts or ends with a backtick, or starts and ends with a space and is not spaces only (`Markdown.hs:479`) +- `SegmentRange`: `[from, to)` into `formattedText`. +- JSON of `BlockToken` and `MarkdownBlock`: `sumTypeJSON fstToLower`, as `Format` (`Markdown.hs:536`). +- An unknown block tag is decoded as `UnknownBlock`, as in `Format` (`Markdown.hs:538-539`). +- An unknown token tag: the format is decoded as `Unknown`. +- `Bold` without `singleStar` in JSON is decoded with `singleStar = False`: the field type is `BoolDef` (`Types.hs:2313-2318`). +- JSON of `ListEntry`, `SegmentRange` and `ParsedMarkdown`: `defaultJSON`. +- JSON of `TableAlign`: `enumJSON (dropPrefix "TA")`. +- Field names equal to local names in importing modules (`-Werror=name-shadowing`, simplex-chat.cabal:362) are hidden in those imports. + +Values: + +``` +bold {"type":"bold","singleStar":true} +escape {"type":"escape"} +lineBreak {"type":"markup","token":{"type":"lineBreak"}} +softBreak {"type":"markup","token":{"type":"softBreak"}} +hardBreak {"type":"markup","token":{"type":"hardBreak"}} +headingClose {"type":"markup","token":{"type":"headingClose"}} +headingUnderline {"type":"markup","token":{"type":"headingUnderline","level":1}} +paragraphBreak {"type":"markup","token":{"type":"paragraphBreak","lineBreaks":2}} +headingMark {"type":"markup","token":{"type":"headingMark","level":2}} +listMark {"type":"markup","token":{"type":"listMark","number":"2."}} +fenceMark {"type":"markup","token":{"type":"fenceMark","language":"bash"}} +delimiterRow {"type":"markup","token":{"type":"delimiterRow","columns":["none","center"]}} +paragraph {"type":"paragraph","content":{"from":1,"to":4}} +codeBlock {"type":"codeBlock","language":"bash","content":{"from":2,"to":5}} +list {"type":"list","items":[{"number":"1.","content":{"from":4,"to":9},"items":[]}]} +table {"type":"table","columns":["none"],"header":[{"from":1,"to":2}],"rows":[[{"from":6,"to":7}]]} +rule {"type":"rule","mark":{"from":2,"to":3}} +``` + +- `language` and `number` are omitted when absent. + +## 3. Pass 1: tokens + +``` +input the text without its final line break +output segments in source order + the concatenation of their texts is the input +``` + +Block parsers, tested in this order at a line start: + +``` +tableP fenceP headingP ruleP listItemP quoteP paragraphP +``` + +- Block start (`blockStartP`): a table start, a fence, a heading line, a rule, a list item line, a quote start. +- Between blocks: `LineBreak`, or `ParagraphBreak` with the blank lines. + +Continuation lines: + +``` +block next line its syntax tokens +paragraph text, not a block start, not an underline none: the indentation is content +list item text, not a block start Indent, when indented +quote a quote line with text QuoteMark + a blank quote line QuoteMark: the whole line + after a line with text: a line with text, none + without ">", not a block start +table a line with "|", not a block start the row tokens +code each line before the closing fence Indent, then the code +``` + +- An unindented line after a list item is a continuation of it (B11; CommonMark 0.31.2 section 5.2, rule 5). +- A paragraph line after a quote line is a continuation of the quote (CommonMark 0.31.2 section 5.1, example 247). + +Inline content of a paragraph, a list item, a quote (`inlineLines`): + +``` +lines the text of each line after its syntax tokens +hard break two or more spaces, or an odd number of "\", at the end of a line with a next line: removed from the line +runs consecutive lines with text; a blank line is a run alone +parse each run joined with "\n", parsed by markdownP; the runs joined with "\n" +breaks each "\n" replaced by the break token and the syntax tokens of the next line + a segment with "\n" is divided there; empty parts without a format are dropped +break HardBreak: after a hard break, or with a blank line before or after it + SoftBreak: otherwise +``` + +- `markdownP` keeps each "\n" of its input in the texts of its segments. + +Texts of the markup tokens: + +``` +LineBreak a line break between two blocks +SoftBreak a soft break (section 1) +HardBreak a hard break (section 1): the spaces or the backslash, and the line break + the line break before and after a blank quote line inside a quote + the line break between the lines of a code block, between the lines of a table, before a setext underline +ParagraphBreak n a line break and the blank lines after it, each with its line break + the blank lines at the text start, with their line breaks + n: the number of "\n" +Indent the indentation of a continuation line of a list item + in a code line: the white space up to the column of the opening fence +HeadingMark the indentation, the "#" run, the space +HeadingClose the closing sequence of a heading line (section 1) +HeadingUnderline the underline line (section 1); level 1 for "=", 2 for "-" +ListMark the indentation, the marker, the space +QuoteMark the indentation, ">", the optional space or tab + in a quote line with a text of white space only: also that text +FenceMark the opening or the closing fence line +RowMark the indentation, the "|" at the line start, the white space before the first cell + empty in a row without them +CellMark after a cell before the last: its white space, the "|", the white space before the next cell + after the last cell: its white space, the "|" at the line end, the white space after it + empty after a last cell without them +DelimiterRow the delimiter row +RuleMark the rule line +``` + +- Inside a code block the lines are divided by `HardBreak` only, blank code lines and spaces at line ends included. +- Spaces at a line end before a line break other than a hard break: content, as now. + +Tokens of a line, in order: + +``` +paragraph line inline content, the indentation included +heading HeadingMark, inline content, HeadingClose when present +underline HeadingUnderline +list item ListMark, inline content +continuation Indent when indented, inline content +quote line QuoteMark, inline content +fence line FenceMark +code line Indent when indented, one segment without format when not empty +table row RowMark, then for each cell: inline content, CellMark +delimiter row DelimiterRow +rule RuleMark +``` + +Changes to `Markdown.hs`: + +``` +parseMaybeMarkdownList (148-167) formattedText of parsedMarkdown +parsedMarkdown new: the command rule (149-157), as now + T.lines (162), intercalate (163): replaced by pass 1 + the join (164-167): joinSegments, markup tokens not joined +documentP, block parsers new (above) +inlineLines new (above) +fragmentP (209) '\n': "\n" without format +'*' case (214) doubleP '*' (Bold False); then formattedP '*' (Bold True) +'_' case (215) doubleP '_' (Bold False); then formattedP '_' Italic +'~' case (216) doubleP '~' StrikeThrough; then formattedP '~' StrikeThrough +'`' case (217) code span (section 1), in place of formattedP '`' Snippet +'<' case new: autolink (section 1); then wordP +'\' case new: escape (section 1); then wordP +formattedP (228-230) content to the delimiter within the line, as now + without the delimiter in the line: across lines (section 1) +boldP (236-241) doubleP: "**", "__", "~~" + white space at the content ends by isSpace +secret (245-249) white space at the content ends by isSpace +styledP (267-272) the space after the style: not "\n" + white space at the content ends by isSpace +sowLinkP (281-293) the format of the link: hyperLinkFormat, shared with autolinks +wordP (320) to a space, "\n", or a backslash before an ASCII punctuation character +secretFallback (251) to a space or "\n" +nameRefP (253) to a space or "\n" +inParens (294) within one line +displayNameTextP_ (512-517) a quoted name: within one line +parseMarkdown (176-177) unchanged: the inline parser, used by msgPlain (View.hs:2474) +``` + +## 4. Pass 2: blocks + +``` +blocks the tokens divided at LineBreak and ParagraphBreak; empty parts dropped +kind by the first token of a block + HeadingMark Heading + ListMark list entry: the column of its indentation, its number + QuoteMark Quote + FenceMark CodeBlock + RowMark Table + RuleMark Rule: mark, the range of the RuleMark token + other Paragraph; Heading when the last token is HeadingUnderline +lists consecutive list entries: one List, nested by column (section 1) +``` + +Ranges: + +``` +content from the first segment without Markup to the last + the breaks and the markup between them included +code from the token after the line break of the opening fence to the line break before the closing fence, excluded + without a closing fence: to the end +cell from the token after its RowMark or CellMark to its CellMark + empty for an empty cell +row from a RowMark to the next RowMark +columns the DelimiterRow +``` + +### Texts without blocks + +Decided 2026-10-17. + +``` +condition pass 1 yields no markup other than LineBreak, SoftBreak, HardBreak and ParagraphBreak + or more than 2000 markup tokens (maxMarkupTokens; decided 2026-10-18) +result each markup token a segment without format, joined by joinSegments + Nothing when no segment has a format, as now + markdownBlocks: Nothing +``` + +- `Escape` is an inline format: kept in texts without blocks. + +### Example + +Text: + +``` +# Plan +- buy *milk* + and eggs + - nested + +> a +> b +``` + +`formattedText`: + +``` +0 headingMark 1 "# " +1 "Plan" +2 lineBreak "\n" +3 listMark "- " +4 "buy " +5 bold "milk" +6 softBreak "\n" +7 indent " " +8 "and eggs" +9 lineBreak "\n" +10 listMark " - " +11 "nested" +12 paragraphBreak 2 "\n\n" +13 quoteMark "> " +14 "a" +15 softBreak "\n" +16 quoteMark "> " +17 "b" +``` + +`markdownBlocks`: + +``` +Heading 1 [1, 2) +List + ListEntry [4, 9) + ListEntry [11, 12) +Quote [14, 18) +``` + +Format across a line break, `"> *a\n> b*"`: + +``` +0 quoteMark "> " +1 bold "a" +2 softBreak "\n" +3 quoteMark "> " +4 bold "b" +Quote [1, 5) +``` + +## 5. Records and parse sites + +`Markdown.hs`: + +```haskell +parsedMarkdown :: Text -> ParsedMarkdown +parseMaybeMarkdownList :: Text -> Maybe MarkdownList -- formattedText of parsedMarkdown +``` + +Field `markdownBlocks :: Maybe (NonEmpty MarkdownBlock)` after `formattedText`: + +``` +ChatItem Messages.hs:265 +CIQuote Messages.hs:614 +ChatItemVersion Messages.hs:1362 +ParsedMarkdown Markdown.hs:143 +WebMessage Web.hs:103 +``` + +Other new fields, of the same type: + +``` +WebChannelPreview Web.hs:114-115 shortDescriptionBlocks, welcomeMessageBlocks +DirectoryEntry Directory/Listing.hs:94-95 shortDescrBlocks, welcomeMessageBlocks +``` + +`(Text, Maybe MarkdownList)` becomes `(Text, ParsedMarkdown)`: + +``` +Library/Internal.hs:365, 368 msgContentTexts, ciContentTexts +Library/Internal.hs:2993 itemTexts +Library/Internal.hs:3030, 3034, 3037 saveRcvChatItem, ciContentNoParse, saveRcvChatItem' +Library/Internal.hs:3088 mkChatItem_ +Library/Internal.hs:3406, 3416 createLocalChatItems +Library/Commands.hs:5155, 5263 ComposedMessageReq and its list +``` + +- `ciContentNoParse` (`Library/Internal.hs:3034-3035`): `ParsedMarkdown Nothing Nothing`. + +Parse sites: + +``` +Store/Messages.hs:693, 2305 CIQuote: parsedMarkdown of the quoted text +Store/Messages.hs:1122, 2334, 2426, 2583 ChatItem: parsedMarkdown of the item text +Store/Messages.hs:3373 ChatItemVersion +Store/Messages.hs:1284, 1630, 1870, 2864, 2936 markdownBlocks = Nothing, with formattedText = Nothing +Library/Internal.hs:226 CIQuote: markdownBlocks of the quoted item +Library/Commands.hs:4931 CIQuote: markdownBlocks of the quoted item +Messages.hs:1369-1379 mkItemVersion: markdownBlocks of the item +Web.hs:252-253, 325 parsedMarkdown of the description texts; its two fields set the two sibling fields of the record + toFormattedText (426-431) removed: equal to parseMaybeMarkdownList +Directory/Listing.hs:143-144, 197-198 toFormattedText returns the list and the blocks; they set the two sibling fields of the record +Store/Messages.hs:693, 2305 mkCIQuote (Messages.hs): CIQuote with the parsed text of its content +Mobile.hs:396 chatParseMarkdown: J.encode . parsedMarkdown . safeDecodeUtf8 +``` + +- In `updatedMentionNames` (`Library/Internal.hs:291-322`), segments are mapped one to one, so the ranges of `markdownBlocks` are valid for its result. + +## 6. Consumers + +Unchanged: + +``` +mentionedNames (Markdown.hs:71-76) a mention in code is text +hasLinks (Markdown.hs:192-193) a link in code is text +styleMarkdownList, styleFormat (Styled.hs:38-47) markup: its text without style (sgr, Styled.hs:71-79) +updatedMentionNames (Library/Internal.hs:291-322) segments mapped one to one +prohibitedSimplexLinks (Library/Internal.hs:426-429) +removeSimplexLink (Library/Internal.hs:1369-1380) the kept text includes the markup texts +hasObfuscatedSimplexLink (Markdown.hs:195-203) a SimpleX link in code is found in the whole text +msgText (Terminal/Output.hs:231-237) +Directory/Listing.hs:52-60 +Directory/Service.hs:599-600 +Directory/Events.hs:106 +msgPlain (View.hs:2474) parseMarkdownList per line, as now +``` + +`Bold {singleStar}` sites: + +``` +sgr (Styled.hs:73) Bold {}: bold, as now +bold' (View.hs:2172) styled (Bold False) +Help.hs:312 markdown (Bold True) "bold text" +``` + +## 6a. Directory service + +``` +spam filter reply (Directory/Service.hs:1160-1163) each command line ends with two spaces: hard breaks between the command lines +tests/Bots/DirectoryTests.hs unchanged: the terminal output of the tests has no trailing spaces +``` + +## 7. Docs and libraries + +`bots/src/API/Docs/Types.hs`: + +``` +chatTypesDocsData, next to :288-289 (sti @BlockToken, STUnion, "", [], "", "") + (sti @MarkdownBlock, STUnion, "", ["UnknownBlock"], "", "") + (sti @ListEntry, STRecord, "", [], "", "") + (sti @SegmentRange, STRecord, "", [], "", "") + (sti @TableAlign, STEnum, "TA", [], "", "") +the Format entry (:288) description: markup tokens, their text as source syntax; Escape; Bold singleStar +next to :521-522 deriving instance Generic for BlockToken, MarkdownBlock, ListEntry, SegmentRange, TableAlign +``` + +Written by the "Bot API docs" test (`tests/APIDocs.hs`): + +``` +bots/api/TYPES.md +the type files of the TypeScript library +the type files of the Python library +``` + +## 8. Compatibility + +- Protocol: unchanged. +- Storage: unchanged. +- A peer with an older core shows the source text with inline formats. +- Items stored earlier are parsed when read (`Store/Messages.hs:1122, 2334, 2426`). Texts drawn as blocks after the change: + +``` +text now after +# of users: 5 text heading +8) nice text list item "8)" +- a text list item ++ a text list item +* a text list item +> cmd text quote +--- text rule +*** text rule +___ text rule +a line with "|" above "-|-" text table +*a text bold, across lines +b* +Title text heading 2 +--- +Title text heading 1 +=== +~~~ fences text code block +\# a text "# a", the "\" not drawn +~~x~~ text strike-through +__x__ text bold +``x`` text code +` a ` text code "a" + text link +``` + +- A quote continued by a line without ">": the line is drawn in the quote. + +- In texts with blocks, after the change: `*x*` drawn as italic; single line breaks in paragraphs, list items and quotes drawn as spaces (rendering plan, section 3). +- `has_link` of earlier items is unchanged. +- A link in code is text. +- A SimpleX link in code is found by `hasObfuscatedSimplexLink` (`Library/Internal.hs:429, 1375, 1380`). +- A SimpleX link in code of a compose field: not found by the app checks of the segments (`ComposeView.kt:435-445`, `ShareListView.kt:81-84`, `ChatUtils.swift:104-113`); the core rejects the message where SimpleX links are prohibited (`Library/Internal.hs:429`), accepted. +- A group link in code of a directory group description: not found by `descriptionContainsLink` (`Directory/Listing.hs:60`); the link line is added to the listing (`Directory/Listing.hs:129-134`), accepted. +- A format across lines, rewritten by `updatedMentionNames`: delimiters on each line, `"> *a\n> b*"` written as `"> *a*\n> *b*"`. + +Costs: + +``` +JSON each line break and each block syntax: one markup segment, in texts with blocks + markdownBlocks: the ranges of the blocks +parsing one parse of the whole text, on each read of an item +``` + +Consumers of earlier releases: + +``` +web pages a markup token: its text, by the default case (website/src/js/simplex-lib.jsc:119-120) + new fields ignored +bots the text of each segment, as now + the Format union has one more type; records have new fields +apps remote desktop only: minRemoteCtrlVersion and minRemoteHostVersion (Remote.hs:77-82) are the version of the release, set at its version change +``` + +## 9. Tests + +`tests/MarkdownTests.hs`: + +``` +operators ==>> (:48-49), <<== (:51-52), <<==>> (:54-55): unchanged + multilineMarkdownList (:463-481): unchanged +new operator ===> for parsedMarkdown: formattedText and markdownBlocks +new spec blockMarkdown +``` + +`blockMarkdown`, the tokens and the blocks of each text: + +``` +heading each level + "#", "# ", "#x", "#\tx#": no blocks + closing sequence: "# a #", "# a ## "; "# a#": "a#" as content +setext "a\n===": heading 1; "a\n---": heading 2; "a\nb\n=": heading 1 of two lines + "- - -" under a text line: rule + "===" under a list item, a quote, a heading, at the text start: text +list each bullet marker + numbers as written, with each delimiter + nesting by 2, 3 and 4 columns + tab indentation + depth above 7 + an indented continuation line + an unindented continuation line + a blank line between items: one list + a paragraph after a list + a list item after a heading +quote ">" with and without a space after it + ">" alone: no blocks + "> a\n>\n> b": one quote with an empty line + ">\n> a\n>": the empty lines outside the content + "> > b": "> b" is text + "> a\nb": one quote, SoftBreak + "> a\nb\n> c": one quote + "> a\n\nb": a quote and a paragraph +code closed + "~~~" fences; "```" not closed by "~~~"; an info string with "`" after "~~~" + open at the end of the text + an empty code line, blank code lines at its end + an info string of two words + indentation of the fence + a tab across the column of the fence + a closing fence with white space after it + inline syntax in code: text + "```", "```\n", "a\n```": no blocks + "```\n```", "```\n\n```", "```\n \n```", "```\n```\nx": code blocks +rule each character + "- - -" + "---" under a text line +table each alignment + a row shorter than the header + a row longer than the header, with and without "|" at its end + an empty cell, a cell of white space only, "||" between cells + rows without "|" at the line start: empty RowMark + indented rows + "a | b\n- | -": a table + a header row without a delimiter row: no blocks + a table ended by a line without "|" +paragraph a paragraph line with indentation: Indent as text + blank lines before the first block and after the last block +across bold, italic, strike-through, snippet, secret, colored across a line break: in a paragraph, a quote, a list item + not across a blank line, a heading line, a cell + an unclosed format: the delimiter and the rest of the line as text; formats on the next lines kept + a delimiter with white space at the content end across lines: as now, within the line +breaks ParagraphBreak counts + CR before each line break, in each block kind + final line break removed + hard break: two and three spaces, and a backslash, before a line break in a paragraph, a list item, a quote + one space before a line break: SoftBreak, the space as content + two spaces or a backslash before a blank line, at the text end, in code: content + SoftBreak in a paragraph, a list entry, a quote; HardBreak between table rows and code lines; LineBreak between blocks +escapes "C:\dir\file.txt": text, one word +bold "*x*": singleStar True + "**x**", "__x__": singleStar False +gfm "~~x~~": StrikeThrough + "`` x ` y ``": Snippet "x ` y"; "```a```" in a line: Snippet "a"; "`` a`": text + "` a `": Snippet "a"; "` a`": Snippet " a"; "` `": Snippet " "; "` a `": Snippet " a " + "": HyperLink, text ""; "": HyperLink to "mailto:a@b.c"; "": text +escapes "\*a*": Escape, "*a*" as text; "\# a" at a line start: no heading + "a\_b": Escape inside a word; "\a": text + in code and snippets: text + in a text without blocks: kept +command "/link 1": as now +live "```", then "```\nls": no blocks, then a code block + "---", then "---x" + an opening fence, then three backticks in the same line + a delimiter row, then more text in it +``` + +Properties, for each text above: + +``` +source T.concat (map markdownText formattedText) is the text without its final line break, except: + colour names written as digits, as now (Markdown.hs:295-309, 494-503) + formats across lines, delimited on each line; an empty part at a line break written as empty delimiters + ~~x~~ written ~x~ + __x__ written **x** + code spans written with the delimiters of markdownText (section 2) +ranges within formattedText, in source order, not overlapping +content each segment without markup is in one content range or one cell +``` + +Consumers: + +``` +mentionedNames a mention in a list item, a quote, a heading, a table cell is found + a mention in code and in a snippet is not found +hasLinks a URI in a list item and a table cell is found; in code not found +SimpleX links in a list item and a table cell: a SimplexLink segment + in code: no segment; found by hasObfuscatedSimplexLink + the text without SimpleX link segments, as removeSimplexLink (Library/Internal.hs:1369-1380) +markdownText each markup token +``` + +- The consumers above are tested on parsed text in `tests/MarkdownTests.hs`; chat tests are not added. + +Other modules: + +``` +tests/ChatTests/Groups.hs:8427 testUpdatedMentionNames + text: a list, a table, a code block + a renamed mention outside code + the same mention in code + the new text: the source with the new name outside code +tests/JSONFixtures.hs a message with a heading, a list and a bold segment, in both encodings +tests/JSONTests.hs the fixtures, for owsf2tagged + a round trip of each block and each markup token + an unknown block tag is UnknownBlock +``` + +Linear time: + +``` +phone numbers the scan ends after 22 characters (master: to the end of the digit groups) +link text "[x]" the scan ends at "[" or a line break +link target "(x)" the scan ends at white space +autolink "" the scan ends at "<" or white space +segment joining each run of equal formats joined once (T.concat) +block starts a table start: two lines; a fence: one line and a line break +table cells one pass over the row and cell marks +``` + +- Tests: the bounded scans keep master's results; texts of 4000 repetitions of each adversarial pattern are parsed; 1000 list items have blocks, 1001 have none. + +## Interfaces + +``` +types Format: Markup, Escape, Bold {singleStar} + BlockToken + MarkdownBlock + ListEntry + SegmentRange + TableAlign + ParsedMarkdown: markdownBlocks +records markdownBlocks: ChatItem, CIQuote, ChatItemVersion, WebMessage + shortDescriptionBlocks, welcomeMessageBlocks: WebChannelPreview + shortDescrBlocks, welcomeMessageBlocks: DirectoryEntry +functions parsedMarkdown + parseMaybeMarkdownList: formattedText of parsedMarkdown +constants maxListDepth 7 + maxMarkupTokens 2000 +``` + +`2026-10-13-block-markdown-rendering.md`: + +``` +the Kotlin types +the iOS types +the web page script +the drawing of the blocks +``` diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 56ed65fb4b..5b0b5b1b3a 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -22,7 +22,7 @@ import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Markdown import Simplex.Chat.Styled -import Simplex.Chat.Types (LocalProfile (..), User (..)) +import Simplex.Chat.Types (BoolDef (..), LocalProfile (..), User (..)) import System.Console.ANSI.Types highlight :: Text -> Markdown @@ -309,7 +309,7 @@ markdownInfo = map styleMarkdown [ green "Markdown:", - indent <> highlight "*bold* " <> " - " <> markdown Bold "bold text", + indent <> highlight "*bold* " <> " - " <> markdown (Bold $ BoolDef True) "bold text", indent <> highlight "_italic_ " <> " - " <> markdown Italic "italic text" <> " (shown as underlined)", indent <> highlight "~strikethrough~" <> " - " <> markdown StrikeThrough "strikethrough text" <> " (shown as inverse)", indent <> highlight "`code snippet` " <> " - " <> markdown Snippet "a + b // no *markdown* here", diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 07839dd032..788b29237d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -834,7 +834,7 @@ processChatCommand cxt nm = \case CTGroup -> withGroupLock "updateChatItem" chatId $ do g@(GIK gInfo@GroupInfo {groupId, membership} _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user chatId when (isNothing scope) $ assertUserGroupRole gInfo GRAuthor - let (_, ft_) = msgContentTexts mc + let (_, ParsedMarkdown ft_ _) = msgContentTexts mc if prohibitedSimplexLinks gInfo membership mc ft_ then throwCmdError ("feature not allowed " <> T.unpack (groupFeatureNameText GFSimplexLinks)) else do @@ -4923,12 +4923,12 @@ processChatCommand cxt nm = \case fl_ <- liftIO $ ciffForwardLink db ciff pure (mcForward fl_ mc, Nothing) (Just qiId, Nothing) -> do - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, markdownBlocks, file} <- getDirectChatItem db user contactId qiId (origQmc, qd, sent) <- quoteData qci let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} qmc = quoteContent mc origQmc file - quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} + quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText, markdownBlocks} pure (mcQuote QuotedMsg {msgRef, content = qmc} mc, Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote pure (mc' {file = fInv_, ttl = ttl' <$> timed_, live = justTrue live}, quotedItem_) @@ -4970,7 +4970,7 @@ processChatCommand cxt nm = \case findProhibited :: [ComposedMessageReq] -> Maybe GroupFeature findProhibited = foldr' - (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft), _) acc -> prohibitedGroupContent gInfo membership chatScopeInfo mc ft fileSource True <|> acc) + (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ParsedMarkdown ft _), _) acc -> prohibitedGroupContent gInfo membership chatScopeInfo mc ft fileSource True <|> acc) Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do @@ -5152,7 +5152,7 @@ processChatCommand cxt nm = \case prepareLocalItemsData :: NonEmpty ComposedMessageReq -> NonEmpty (Maybe (CIFile 'MDSnd)) -> - NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) prepareLocalItemsData = L.zipWith $ \(ComposedMessage {msgContent = mc}, itemForwarded, ts, _) f -> (CISndMsgContent mc, f, itemForwarded, ts) @@ -5260,7 +5260,7 @@ updatedRelays relays UserOperatorServers {operator, smpServers, xftpServers, cha disableRelay relay@UserChatRelay {preset} = AUCR SDBStored $ if preset then relay {enabled = False} else relay {deleted = True} -type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList), Map MemberName CIMention) +type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, ParsedMarkdown), Map MemberName CIMention) composedMessage :: Maybe CryptoFile -> MsgContent -> ComposedMessage composedMessage f mc = ComposedMessage {fileSource = f, quotedItemId = Nothing, msgContent = mc, mentions = M.empty} @@ -5270,7 +5270,7 @@ composedMessageReq cm@ComposedMessage {msgContent = mc} = (cm, Nothing, msgConte composedMessageReqMentions :: DB.Connection -> User -> GroupInfo -> ComposedMessage -> ExceptT StoreError IO ComposedMessageReq composedMessageReqMentions db user g cm@ComposedMessage {msgContent = mc, mentions} = do - let ts@(_, ft_) = msgContentTexts mc + let ts@(_, ParsedMarkdown ft_ _) = msgContentTexts mc (cm,Nothing,ts,) <$> getCIMentions db user g ft_ mentions data ChangedProfileContact = ChangedProfileContact diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index ced5c8c676..d4e9893bd9 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -217,13 +217,13 @@ prepareGroupMsg db user g@GroupInfo {membership} msgScope showGroupAsSender mc m fl_ <- liftIO $ ciffForwardLink db ciff pure (mcForward fl_ mc, Nothing) (Just quotedItemId, Nothing) -> do - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, markdownBlocks, mentions = quoteMentions, file} <- getGroupCIWithReactions db user g quotedItemId (origQmc, qd, sent, member_) <- quoteData qci membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = memberId' <$> member_} qmc = quoteContent mc origQmc file (qmc', ft', _) = updatedMentionNames qmc formattedText quoteMentions - quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft'} + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft', markdownBlocks} pure (mcQuote QuotedMsg {msgRef, content = qmc'} mc, Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote let mc'' = mc' {mentions = MsgMentions mentions, file = fInv_, ttl = ttl' <$> timed_, live = justTrue live, scope = msgScope, asGroup = justTrue showGroupAsSender} @@ -362,11 +362,11 @@ getMessageMentions db user gId msg = case parseMaybeMarkdownList msg of fmap (name,) . eitherToMaybe <$> runExceptT (getGroupMemberIdByName db user gId name) -msgContentTexts :: MsgContent -> (Text, Maybe MarkdownList) -msgContentTexts mc = let t = msgContentText mc in (t, parseMaybeMarkdownList t) +msgContentTexts :: MsgContent -> (Text, ParsedMarkdown) +msgContentTexts mc = let t = msgContentText mc in (t, parsedMarkdown t) -ciContentTexts :: CIContent d -> (Text, Maybe MarkdownList) -ciContentTexts content = let t = ciContentToText content in (t, parseMaybeMarkdownList t) +ciContentTexts :: CIContent d -> (Text, ParsedMarkdown) +ciContentTexts content = let t = ciContentToText content in (t, parsedMarkdown t) quoteContent :: forall d. MsgContent -> MsgContent -> Maybe (CIFile d) -> MsgContent quoteContent mc qmc ciFile_ @@ -2993,7 +2993,7 @@ saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed l data NewSndChatItemData c = NewSndChatItemData { msg :: SndMessage, content :: CIContent 'MDSnd, - itemTexts :: (Text, Maybe MarkdownList), + itemTexts :: (Text, ParsedMarkdown), itemMentions :: Map MemberName CIMention, ciFile :: Maybe (CIFile 'MDSnd), quotedItem :: Maybe (CIQuote c), @@ -3018,8 +3018,8 @@ saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData) where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) - createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId, signedMsg_}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do - let hasLink_ = ciContentHasLink content (snd itemTexts) + createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId, signedMsg_}, content, itemTexts = itemTexts@(_, ParsedMarkdown ft_ _), itemMentions, ciFile, quotedItem, itemForwarded} = do + let hasLink_ = ciContentHasLink content ft_ ciId <- createNewSndChatItem db user cd showGroupAsSender msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing (toMsgVerified (signMessagesRequired cd) (MSSVerified <$ signedMsg_)) createdAt @@ -3030,15 +3030,15 @@ saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItemNoParse user cd msg brokerTs = saveRcvChatItem user cd msg brokerTs . ciContentNoParse -saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv, ChatInfo c) +saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, ParsedMarkdown)) -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty -ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -ciContentNoParse content = (content, (ciContentToText content, Nothing)) +ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, ParsedMarkdown)) +ciContentNoParse content = (content, (ciContentToText content, ParsedMarkdown Nothing Nothing)) -saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c) -saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do +saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, ParsedMarkdown)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c) +saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMember} sharedMsgId_ brokerTs (content, ts@(_, ParsedMarkdown ft_ _)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime cxt <- chatStoreCxt withStore' $ \db -> do @@ -3054,7 +3054,7 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem itemForwarded <- rcvForwardedFrom db user cd msg (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemForwarded itemTimed live userMention hasLink_ brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd showAsGroup ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember (toMsgVerified (signMessagesRequired cd) msgSigned) createdAt + let ci = mkChatItem_ cd showAsGroup ciId content ts ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember (toMsgVerified (signMessagesRequired cd) msgSigned) createdAt ci' <- case toChatInfo cd of GroupChat g _ | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci @@ -3084,15 +3084,15 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem -- TODO [mentions] optimize by avoiding unnecessary parsing mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> ChatItem c d mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember msgVerified currentTs = - let ts@(_, ft_) = ciContentTexts content + let ts@(_, ParsedMarkdown ft_ _) = ciContentTexts content hasLink_ = ciContentHasLink content ft_ in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs -mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> ChatItem c d -mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs = +mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, ParsedMarkdown) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> ChatItem c d +mkChatItem_ cd showGroupAsSender ciId content (itemText, ParsedMarkdown formattedText markdownBlocks) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs = let itemStatus = ciCreateStatus content meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender msgVerified currentTs currentTs - in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, quotedItem, reactions = [], file} + in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem, reactions = [], file} ciContentHasLink :: CIContent d -> Maybe MarkdownList -> Bool ciContentHasLink content ft_ = case ciMsgContent content of @@ -3406,7 +3406,7 @@ memberAttentionChange unread brokerTs_ rcvMem_ = \case createLocalChatItems :: User -> ChatDirection 'CTLocal 'MDSnd -> - NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) -> UTCTime -> CM [ChatItem 'CTLocal 'MDSnd] createLocalChatItems user cd itemsData createdAt = do @@ -3416,8 +3416,8 @@ createLocalChatItems user cd itemsData createdAt = do unless (null errs) $ toView $ CEvtChatErrors errs pure items where - createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) - createItem db (content, ciFile, itemForwarded, ts@(_, ft_)) = do + createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) -> IO (ChatItem 'CTLocal 'MDSnd) + createItem db (content, ciFile, itemForwarded, ts@(_, ParsedMarkdown ft_ _)) = do let hasLink_ = ciContentHasLink content ft_ ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing Nothing Nothing createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index ead232b3be..dfcd1005ab 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -52,6 +52,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Delivery import Simplex.Chat.Files (getChatTempDirectory, safeFileNameStr) import Simplex.Chat.Library.Internal +import Simplex.Chat.Markdown (ParsedMarkdown (..)) import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved) import Simplex.Chat.Messages import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize) @@ -2245,7 +2246,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag | otherwise = (\GroupMember {memberId} -> encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId))) <$> m_ GroupInfo {groupProfile = GroupProfile {publicGroup}} = gInfo sentAsGroup = asGroup_ == Just True - ts@(_, ft_) = msgContentTexts content + ts@(_, ParsedMarkdown ft_ _) = msgContentTexts content -- m' is Maybe GroupMember saveRcvCI gInfo' m' scopeInfo = let itemMember_ = if sentAsGroup then Nothing else m' @@ -2334,7 +2335,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag pure $ Just $ infoToDeliveryContext gInfo' scopeInfo showGroupAsSender where content = CIRcvMsgContent mc - ts@(_, ft_) = msgContentTexts mc + ts@(_, ParsedMarkdown ft_ _) = msgContentTexts mc live = fromMaybe False live_ updateRcvChatItem = do (cci, scopeInfo) <- withStore $ \db -> do diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index c90cd54fab..996a9449cd 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -19,17 +19,19 @@ import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as AB +import Data.Attoparsec.Combinator (lookAhead) import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A +import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace) -import Data.Either (fromRight) +import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace, isSymbol) +import Data.Either (fromRight, isLeft, lefts) import Data.Functor (($>)) -import Data.List (foldl', intercalate) -import Data.List.NonEmpty (NonEmpty) +import Data.List (intercalate) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L -import Data.Maybe (fromMaybe, isNothing, mapMaybe) +import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe) import Data.Semigroup (sconcat) import Data.String import Data.Text (Text) @@ -49,7 +51,7 @@ data Markdown = Markdown (Maybe Format) Text | Markdown :|: Markdown deriving (Eq, Show) data Format - = Bold + = Bold {singleStar :: BoolDef} | Italic | StrikeThrough | Snippet @@ -65,9 +67,49 @@ data Format | Mention {memberName :: Text} | Email | Phone + | Escape + | Markup {token :: BlockToken} | Unknown {json :: J.Value} deriving (Eq, Show) +data BlockToken + = LineBreak + | SoftBreak + | HardBreak + | ParagraphBreak {lineBreaks :: Int} + | Indent + | HeadingMark {level :: Int} + | HeadingClose + | HeadingUnderline {level :: Int} + | ListMark {number :: Maybe Text} + | QuoteMark + | FenceMark {language :: Maybe Text} + | RowMark + | CellMark + | DelimiterRow {columns :: [TableAlign]} + | RuleMark + deriving (Eq, Show) + +data MarkdownBlock + = Paragraph {content :: SegmentRange} + | Heading {level :: Int, content :: SegmentRange} + | CodeBlock {language :: Maybe Text, content :: SegmentRange} + | Quote {content :: SegmentRange} + | List {items :: NonEmpty ListEntry} + | Table {columns :: NonEmpty TableAlign, header :: [SegmentRange], rows :: [[SegmentRange]]} + | Rule {mark :: SegmentRange} + | UnknownBlock {json :: J.Value} + deriving (Eq, Show) + +data ListEntry = ListEntry {number :: Maybe Text, content :: SegmentRange, items :: [ListEntry]} + deriving (Eq, Show) + +data SegmentRange = SegmentRange {from :: Int, to :: Int} + deriving (Eq, Show) + +data TableAlign = TANone | TALeft | TACenter | TARight + deriving (Eq, Show) + mentionedNames :: MarkdownList -> [Text] mentionedNames = mapMaybe (\(FormattedText f _) -> mentionedName =<< f) where @@ -140,31 +182,120 @@ instance IsString FormattedText where type MarkdownList = [FormattedText] -data ParsedMarkdown = ParsedMarkdown {formattedText :: Maybe MarkdownList} +data ParsedMarkdown = ParsedMarkdown {formattedText :: Maybe MarkdownList, markdownBlocks :: Maybe (NonEmpty MarkdownBlock)} unmarked :: Text -> Markdown unmarked = Markdown Nothing parseMaybeMarkdownList :: Text -> Maybe MarkdownList -parseMaybeMarkdownList s = case ls of - [] -> Nothing +parseMaybeMarkdownList s = let ParsedMarkdown {formattedText} = parsedMarkdown s in formattedText + +parsedMarkdown :: Text -> ParsedMarkdown +parsedMarkdown s = case T.lines s of + [] -> ParsedMarkdown Nothing Nothing [l] - | T.null cmd -> Nothing - | isCmd -> Just [FormattedText (Just $ Command cmd) l] + | T.null cmd -> ParsedMarkdown Nothing Nothing + | isCmd -> ParsedMarkdown (Just [FormattedText (Just $ Command cmd) l]) Nothing where (isCmd, cmd) = case T.uncons $ T.dropWhile (== ' ') l of Just (c, rest) -> (c == '/', rest) Nothing -> (False, "") _ - | all (isNothing . format) ml -> Nothing - | otherwise -> Just . reverse $ foldl' acc [] ml + | any isBlockSyntax tokens && length [f | FormattedText f _ <- tokens, isMarkup f] <= maxMarkupTokens -> ParsedMarkdown (Just tokens) (L.nonEmpty $ markdownBlocksOf tokens) + | all (isNothing . format) inline -> ParsedMarkdown Nothing Nothing + | otherwise -> ParsedMarkdown (Just inline) Nothing where - ls = T.lines s - ml = intercalate ["\n"] $ map (markdownToList . parseMarkdown) ls - acc [] m = [m] - acc ms@(FormattedText f t : ms') ft@(FormattedText f' t') - | f == f' = FormattedText f (t <> t') : ms' - | otherwise = ft : ms + s' = fromMaybe s $ T.stripSuffix "\r\n" s <|> T.stripSuffix "\n" s + tokens = joinSegments $ fromRight [FormattedText Nothing s'] $ A.parseOnly (documentP <* A.endOfInput) s' + inline = joinSegments $ map breakAsText tokens + breakAsText ft@(FormattedText f t) = if isMarkup f then FormattedText Nothing t else ft + +isBlockSyntax :: FormattedText -> Bool +isBlockSyntax = \case + FormattedText (Just (Markup t)) _ -> case t of + LineBreak -> False + SoftBreak -> False + HardBreak -> False + ParagraphBreak _ -> False + _ -> True + _ -> False + +joinSegments :: MarkdownList -> MarkdownList +joinSegments = map joinGroup . L.groupBy (\(FormattedText f _) (FormattedText f' _) -> f == f' && not (isMarkup f)) + +joinGroup :: NonEmpty FormattedText -> FormattedText +joinGroup g@(FormattedText f _ :| _) = FormattedText f $ T.concat [t | FormattedText _ t <- L.toList g] + +isMarkup :: Maybe Format -> Bool +isMarkup = \case + Just (Markup _) -> True + _ -> False + +markdownBlocksOf :: MarkdownList -> [MarkdownBlock] +markdownBlocksOf = groupLists . mapMaybe (blockOf <=< L.nonEmpty) . splitBlocks . zip [0 ..] + where + splitBlocks ts = case break (isBlockBreak . snd) ts of + (b, _ : rest) -> b : splitBlocks rest + (b, []) -> [b] + isBlockBreak = \case + FormattedText (Just (Markup LineBreak)) _ -> True + FormattedText (Just (Markup (ParagraphBreak _))) _ -> True + _ -> False + +blockOf :: NonEmpty (Int, FormattedText) -> Maybe (Either (Int, Maybe Text, SegmentRange) MarkdownBlock) +blockOf ts@((i, FormattedText f t) :| _) = case f of + Just (Markup tkn) -> case tkn of + HeadingMark lvl -> Right . Heading lvl <$> contentRange + ListMark num -> (\r -> Left (indentColumn $ T.takeWhile isIndentChar t, num, r)) <$> contentRange + QuoteMark -> Right . Quote <$> contentRange + FenceMark lang -> Just $ Right $ CodeBlock lang $ SegmentRange (i + 2) (max (i + 2) codeEnd) + RowMark -> Right <$> tableBlock (L.toList ts) + RuleMark -> Just $ Right $ Rule $ SegmentRange i (i + 1) + _ -> paragraph + _ -> paragraph + where + contentRange = (\is -> SegmentRange (L.head is) (L.last is + 1)) <$> L.nonEmpty [j | (j, FormattedText f' _) <- L.toList ts, not (isMarkup f')] + codeEnd = case L.last ts of + (j, FormattedText (Just (Markup (FenceMark _))) _) -> j - 1 + (j, _) -> j + 1 + paragraph = case L.last ts of + (_, FormattedText (Just (Markup (HeadingUnderline lvl))) _) -> Right . Heading lvl <$> contentRange + _ -> Right . Paragraph <$> contentRange + +tableBlock :: [(Int, FormattedText)] -> Maybe MarkdownBlock +tableBlock ts = do + cols <- L.nonEmpty $ concat [aligns | (_, FormattedText (Just (Markup (DelimiterRow aligns))) _) <- ts] + headerRow : bodyRows <- pure $ tableRows [(i, tkn) | (i, FormattedText (Just (Markup tkn)) _) <- ts, tkn == RowMark || tkn == CellMark] + pure $ Table cols headerRow bodyRows + where + tableRows = \case + (i, RowMark) : marks -> + let (cellMarks, marks') = span ((== CellMark) . snd) marks + js = map fst cellMarks + in zipWith (\a b -> SegmentRange (a + 1) b) (i : js) js : tableRows marks' + _ : marks -> tableRows marks + [] -> [] + +groupLists :: [Either (Int, Maybe Text, SegmentRange) MarkdownBlock] -> [MarkdownBlock] +groupLists = \case + Right b : bs -> b : groupLists bs + bs@(Left _ : _) -> + let (entries, rest) = span isLeft bs + in maybe id ((:) . List) (L.nonEmpty $ nestEntries 1 $ lefts entries) (groupLists rest) + [] -> [] + +nestEntries :: Int -> [(Int, Maybe Text, SegmentRange)] -> [ListEntry] +nestEntries depth = \case + (column, num, r) : rest -> + let (children, siblings) = if depth < maxListDepth then span (\(c, _, _) -> c > column) rest else ([], rest) + in ListEntry num r (nestEntries (depth + 1) children) : nestEntries depth siblings + [] -> [] + +maxListDepth :: Int +maxListDepth = 7 + +maxMarkupTokens :: Int +maxMarkupTokens = 2000 parseMarkdownList :: Text -> MarkdownList parseMarkdownList = markdownToList . parseMarkdown @@ -173,6 +304,13 @@ markdownToList :: Markdown -> MarkdownList markdownToList (Markdown f s) = [FormattedText f s] markdownToList (m1 :|: m2) = markdownToList m1 <> markdownToList m2 +joinMarkdown :: [Markdown] -> Markdown +joinMarkdown ms = case map joinGroup $ L.groupBy sameFormat $ filter (\(FormattedText _ t) -> not $ T.null t) $ concatMap markdownToList ms of + [] -> mempty + fts -> foldr1 (:|:) [Markdown f t | FormattedText f t <- fts] + where + sameFormat (FormattedText f _) (FormattedText f' _) = f == f' + parseMarkdown :: Text -> Markdown parseMarkdown s = fromRight (unmarked s) $ A.parseOnly (markdownP <* A.endOfInput) s @@ -203,54 +341,76 @@ hasObfuscatedSimplexLink t = <|> pure False markdownP :: Parser Markdown -markdownP = mconcat <$> A.many' fragmentP +markdownP = joinMarkdown <$> A.many' fragmentP where fragmentP :: Parser Markdown fragmentP = A.peekChar >>= \case Just c -> case c of ' ' -> unmarked <$> A.takeWhile (== ' ') + '\n' -> unmarked <$> A.string "\n" '+' -> phoneP <|> wordP - '*' -> boldP <|> formattedP '*' Bold - '_' -> formattedP '_' Italic - '~' -> formattedP '~' StrikeThrough - '`' -> formattedP '`' Snippet + '*' -> doubleP '*' (Bold $ BoolDef False) <|> formattedP '*' (Bold $ BoolDef True) + '_' -> doubleP '_' (Bold $ BoolDef False) <|> formattedP '_' Italic + '~' -> doubleP '~' StrikeThrough <|> formattedP '~' StrikeThrough + '`' -> codeSpanP '#' -> A.char '#' *> (secretP <|> nameRefP '#' <|> secretFallback) '!' -> styledP <|> wordP '@' -> (A.char '@' *> nameRefP '@') <|> mentionP <|> wordP '/' -> commandP <|> wordP '[' -> sowLinkP <|> wordP + '<' -> autolinkP <|> wordP + '\\' -> escapeP <|> wordP _ | isDigit c -> phoneP <|> wordP | otherwise -> wordP Nothing -> fail "" formattedP :: Char -> Format -> Parser Markdown formattedP c f = do - s <- A.char c *> A.takeTill (== c) - (A.char c $> md c f s) <|> noFormat (c `T.cons` s) + s <- A.char c *> A.takeTill (\x -> x == c || x == '\n') + (A.char c $> md c f s) <|> (markdown f <$> acrossLinesP s) <|> noFormat (c `T.cons` s) + where + acrossLinesP s = do + s' <- ((s <> "\n") <>) <$> (A.char '\n' *> A.takeTill (== c) <* A.char c) + if T.null s || isSpace (T.head s) || isSpace (T.last s') then fail "not formatted" else pure s' md :: Char -> Format -> Text -> Markdown md c f s | T.null s || T.head s == ' ' || T.last s == ' ' = unmarked $ c `T.cons` s `T.snoc` c | otherwise = markdown f s - boldP :: Parser Markdown - boldP = do - s <- A.string "**" *> A.takeTill (== '*') <* A.string "**" - if T.null s || T.head s == ' ' || T.last s == ' ' - then fail "not bold" - else pure $ markdown Bold s + doubleP :: Char -> Format -> Parser Markdown + doubleP c f = do + s <- A.string cc *> A.takeTill (== c) <* A.string cc + if T.null s || isSpace (T.head s) || isSpace (T.last s) + then fail "not formatted" + else pure $ markdown f s + where + cc = T.pack [c, c] + codeSpanP :: Parser Markdown + codeSpanP = do + ticks <- A.takeWhile1 (== '`') + let pieceP = A.takeWhile1 (/= '`') <|> (A.takeWhile1 (== '`') >>= \r -> r <$ guard (r /= ticks)) + (markdown Snippet . stripSpaces . T.concat <$> A.many' pieceP <* A.string ticks) <|> pure (unmarked ticks) + where + stripSpaces s + | T.isPrefixOf " " s && T.isSuffixOf " " s && T.any (/= ' ') s = T.drop 1 $ T.dropEnd 1 s + | otherwise = s + escapeP :: Parser Markdown + escapeP = do + c <- A.char '\\' *> A.satisfy (\x -> isAscii x && (isPunctuation x || isSymbol x)) + pure $ markdown Escape "\\" <> unmarked (T.singleton c) secretP :: Parser Markdown secretP = secret <$?> ((,,) <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile1 (== '#')) secret :: (Text, Text, Text) -> Either String Markdown secret (b, s, a) - | T.null s || T.head s == ' ' || T.last s == ' ' = Left "not secret" + | T.null s || isSpace (T.head s) || isSpace (T.last s) = Left "not secret" | otherwise = Right $ markdown Secret $ T.init ss where ss = b <> s <> a secretFallback :: Parser Markdown - secretFallback = unmarked . ('#' `T.cons`) <$> A.takeTill (== ' ') + secretFallback = unmarked . ('#' `T.cons`) <$> A.takeTill (\c -> c == ' ' || c == '\n') nameRefP :: Char -> Parser Markdown - nameRefP pfx = nameRef <$?> A.takeTill (== ' ') + nameRefP pfx = nameRef <$?> A.takeTill (\c -> c == ' ' || c == '\n') where nameRef word | pfx == '@' && T.all (/= '.') name = Left "not a name" @@ -265,9 +425,9 @@ markdownP = mconcat <$> A.many' fragmentP md' = markdown (SimplexName ni) full styledP :: Parser Markdown styledP = do - f <- A.char '!' *> ((A.char '-' $> Small) <|> (colored <$> colorP)) <* A.space - s <- ((<>) <$> A.takeWhile1 (\c -> c /= ' ' && c /= '!') <*> A.takeTill (== '!')) <* A.char '!' - if T.null s || T.last s == ' ' + f <- A.char '!' *> ((A.char '-' $> Small) <|> (colored <$> colorP)) <* A.satisfy (\c -> isSpace c && c /= '\n') + s <- ((<>) <$> A.takeWhile1 (\c -> not (isSpace c) && c /= '!') <*> A.takeTill (== '!')) <* A.char '!' + if T.null s || isSpace (T.last s) then fail "not styled" else pure $ markdown f s mentionP = prefixedStringP '@' displayNameTextP_ Mention @@ -279,19 +439,24 @@ markdownP = mconcat <$> A.many' fragmentP res = markdown (format str) (pfx `T.cons` origStr) pure $ if T.null punct then res else res :|: unmarked punct sowLinkP = do - t <- '[' `inParens` ']' - l <- '(' `inParens` ')' + t <- enclosedP '[' ']' (\c -> c == '[' || c == '\n') + l <- enclosedP '(' ')' isSpace let hasPunct = T.any (\c -> isPunctuation c && c /= '-' && c /= '_') t when (hasPunct && t /= l && ("https://" <> t) /= l) $ fail "punctuation in hyperlink text" - f <- case strDecode $ encodeUtf8 l of - Right lnk@(ACL _ cLink) -> case cLink of - CLShort _ -> pure $ simplexUriFormat (Just t) lnk - CLFull _ -> fail "full SimpleX link in hyperlink" - Left _ -> case parseUri $ encodeUtf8 l of - Right _ -> pure $ HyperLink (Just t) l - Left e -> fail $ "not uri: " <> T.unpack e + f <- hyperLinkFormat t l pure $ markdown f $ T.concat ["[", t, "](", l, ")"] - inParens open close = A.char open *> A.takeWhile1 (/= close) <* A.char close + autolinkP = do + l <- enclosedP '<' '>' (\c -> c == '<' || isSpace c) + f <-if isEmail l then pure (HyperLink (Just l) ("mailto:" <> l)) else hyperLinkFormat l l + pure $ markdown f $ "<" <> l <> ">" + hyperLinkFormat t l = case strDecode $ encodeUtf8 l of + Right lnk@(ACL _ cLink) -> case cLink of + CLShort _ -> pure $ simplexUriFormat (Just t) lnk + CLFull _ -> fail "full SimpleX link in hyperlink" + Left _ -> case parseUri $ encodeUtf8 l of + Right _ -> pure $ HyperLink (Just t) l + Left e -> fail $ "not uri: " <> T.unpack e + enclosedP open close stop = A.char open *> A.takeWhile1 (\c -> c /= close && not (stop c)) <* A.char close colorP = A.anyChar >>= \case 'r' -> optional "ed" $> Red @@ -310,14 +475,16 @@ markdownP = mconcat <$> A.many' fragmentP phoneP = do country <- optional $ T.cons <$> A.char '+' <*> A.takeWhile1 isDigit code <- optional $ conc4 <$> phoneSep <*> "(" <*> A.takeWhile1 isDigit <*> ")" - segments <- mconcat <$> A.many' ((<>) <$> phoneSep <*> A.takeWhile1 isDigit) - let s = fromMaybe "" country <> fromMaybe "" code <> segments - len = T.length s + s <- segmentsP $ fromMaybe "" country <> fromMaybe "" code + let len = T.length s if 7 <= len && len <= 22 then pure $ markdown Phone s else fail "not phone" + segmentsP s + | T.length s > 22 = fail "not phone" + | otherwise = optional ((<>) <$> phoneSep <*> A.takeWhile1 isDigit) >>= maybe (pure s) (segmentsP . (s <>)) conc4 s1 s2 s3 s4 = s1 <> s2 <> s3 <> s4 phoneSep = " " <|> "-" <|> "." <|> "" wordP :: Parser Markdown - wordP = wordMD <$> A.takeTill (== ' ') + wordP = wordMD . T.concat <$> A.many' (A.takeWhile1 (\c -> c /= ' ' && c /= '\n' && c /= '\\') <|> (notP escapeP *> A.string "\\")) wordMD :: Text -> Markdown wordMD s | T.null s = unmarked s @@ -377,6 +544,307 @@ markdownP = mconcat <$> A.many' fragmentP strEncodeText :: StrEncoding a => a -> Text strEncodeText = safeDecodeUtf8 . strEncode +documentP :: Parser MarkdownList +documentP = do + blanks <- blankLinesP + ([paragraphBreak blanks | not (T.null blanks)] <>) <$> blocksP + where + blocksP = (<>) <$> blockP <*> A.option [] ((:) <$> blockBreakP <*> blocksP) + +blockBreakP :: Parser FormattedText +blockBreakP = do + br <- lineEndP + blanks <- blankLinesP + pure $ if T.null blanks then markupText LineBreak br else paragraphBreak (br <> blanks) + +blankLinesP :: Parser Text +blankLinesP = (<>) <$> (T.concat <$> A.many' ((<>) <$> A.takeWhile isIndentChar <*> lineEndP)) <*> A.option "" (A.takeWhile1 isIndentChar <* A.endOfInput) + +paragraphBreak :: Text -> FormattedText +paragraphBreak t = markupText (ParagraphBreak $ T.count "\n" t) t + +markupText :: BlockToken -> Text -> FormattedText +markupText = FormattedText . Just . Markup + +blockP :: Parser MarkdownList +blockP = tableP <|> fenceP <|> headingP <|> ruleP <|> listItemP <|> quoteP <|> paragraphP + +blockStartP :: Parser () +blockStartP = void tableStartP <|> void fenceStartP <|> void headingMarkP <|> void ruleP <|> void listMarkP <|> quoteStartP + +paragraphLineP :: Parser () +paragraphLineP = hasTextP *> notP blockStartP + +hasTextP :: Parser () +hasTextP = lookAhead lineTextP >>= guard . not . isBlank + +notP :: Parser a -> Parser () +notP p = ((True <$ lookAhead p) <|> pure False) >>= guard . not + +tableP :: Parser MarkdownList +tableP = do + (headerLine, br, delimiter, aligns) <- tableStartP + let row = tableRowTokens $ length aligns + bodyRows <- A.many' $ (\br' l -> markupText HardBreak br' : row l) <$> lineEndP <* notP blockStartP <*> (lineTextP >>= \l -> l <$ guard (T.any (== '|') l)) + pure $ row headerLine <> [markupText HardBreak br, markupText (DelimiterRow aligns) delimiter] <> concat bodyRows + +tableStartP :: Parser (Text, Text, Text, [TableAlign]) +tableStartP = do + headerLine <- lineTextP + br <- lineEndP + delimiter <- lineTextP + aligns <- maybe mzero pure $ tableStart headerLine delimiter + pure (headerLine, br, delimiter, aligns) + +tableRowTokens :: Int -> Text -> MarkdownList +tableRowTokens n t = case map cellParts cellTexts of + (before, c, after) : more -> markupText RowMark (lead <> before) : cellTokens c after more + [] -> [markupText RowMark lead, markupText CellMark trail] + where + (lead, allCells, trail) = tableLine t + cellTexts = case splitAt (n - 1) allCells of + (ts, rest@(_ : _ : _)) -> ts <> [T.intercalate "|" rest] + _ -> allCells + cellTokens c after = \case + (before', c', after') : more -> cellText c <> [markupText CellMark (after <> "|" <> before')] <> cellTokens c' after' more + [] -> cellText c <> [markupText CellMark (after <> trail)] + cellText c = if T.null c then [] else parseMarkdownList c + cellParts cell = + let before = T.takeWhile isSpace cell + rest = T.drop (T.length before) cell + after = T.takeWhileEnd isSpace rest + in (before, T.dropEnd (T.length after) rest, after) + +fenceP :: Parser MarkdownList +fenceP = do + (l, (col, n, ch, lang)) <- fenceStartP + codeLines <- A.many' $ (,) <$> lineEndP <*> (lineTextP >>= \cl -> cl <$ guard (not $ closingFence n ch cl)) + closing <- optional $ (,) <$> lineEndP <*> lineTextP + pure $ markupText (FenceMark lang) l : concatMap (codeLine col) codeLines <> maybe [] (\(br, cl) -> [markupText HardBreak br, markupText (FenceMark Nothing) cl]) closing + where + codeLine col (br, cl) = + let (ind, code) = dropColumns col cl + in markupText HardBreak br : [markupText Indent ind | not (T.null ind)] <> [FormattedText Nothing code | not (T.null code)] + +fenceStartP :: Parser (Text, (Int, Int, Char, Maybe Text)) +fenceStartP = do + l <- lineTextP + fence <- maybe mzero pure $ fenceLine l + (l, fence) <$ lookAhead lineEndP + +headingP :: Parser MarkdownList +headingP = do + (lvl, syntax) <- headingMarkP + (headingText, closing) <- headingClosing <$> lineTextP + pure $ markupText (HeadingMark lvl) syntax : parseMarkdownList headingText <> [markupText HeadingClose closing | not (T.null closing)] + +headingMarkP :: Parser (Int, Text) +headingMarkP = do + ind <- A.takeWhile isIndentChar + hashes <- A.takeWhile1 (== '#') + guard $ T.length hashes <= 6 + sp <- A.string " " + hasTextP + pure (T.length hashes, ind <> hashes <> sp) + +ruleP :: Parser MarkdownList +ruleP = lineTextP >>= \l -> [markupText RuleMark l] <$ guard (isRule l) + +listItemP :: Parser MarkdownList +listItemP = do + (num, syntax) <- listMarkP + l <- lineTextP + ls <- A.many' $ (\br ind l' -> (br, [markupText Indent ind | not (T.null ind)], l')) <$> lineEndP <* paragraphLineP <*> A.takeWhile isIndentChar <*> lineTextP + pure $ markupText (ListMark num) syntax : inlineLines l ls + +listMarkP :: Parser (Maybe Text, Text) +listMarkP = do + ind <- A.takeWhile isIndentChar + (num, marker) <- ((Nothing,) . T.singleton <$> A.satisfy (`elem` ("-*+" :: String))) <|> numberedP + sp <- A.string " " + hasTextP + pure (num, ind <> marker <> sp) + where + numberedP = do + digits <- A.takeWhile1 isDigit + guard $ T.length digits <= 9 + marker <- T.snoc digits <$> A.satisfy (\c -> c == '.' || c == ')') + pure (Just marker, marker) + +quoteP :: Parser MarkdownList +quoteP = do + leading <- A.many' $ (\l br -> [markupText QuoteMark l, markupText HardBreak br]) <$> blankQuoteLineP <*> lineEndP + (mark, l) <- quoteTextP + ls <- nextLinesP True + pure $ concat leading <> (markupText QuoteMark mark : inlineLines l ls) + where + quoteTextP = (,) <$> quoteMarkP <* hasTextP <*> lineTextP + nextLinesP afterText = A.option [] $ do + br <- lineEndP + (syntax, l, isText) <- quoteLineP <|> blankLineP <|> (if afterText then lazyLineP else mzero) + ((br, syntax, l) :) <$> nextLinesP isText + quoteLineP = (\(m, l) -> ([markupText QuoteMark m], l, True)) <$> quoteTextP + blankLineP = (\l -> ([markupText QuoteMark l], "", False)) <$> blankQuoteLineP + lazyLineP = ([],,True) <$> (notP quoteMarkP *> paragraphLineP *> lineTextP) + +quoteMarkP :: Parser Text +quoteMarkP = do + ind <- A.takeWhile isIndentChar + sp <- A.char '>' *> A.option "" (T.singleton <$> A.satisfy isIndentChar) + pure $ ind <> ">" <> sp + +blankQuoteLineP :: Parser Text +blankQuoteLineP = (<>) <$> quoteMarkP <*> A.takeWhile isIndentChar <* lookAhead (void lineEndP <|> A.endOfInput) + +quoteStartP :: Parser () +quoteStartP = optional (blankQuoteLineP *> lineEndP) *> quoteMarkP *> hasTextP + +paragraphP :: Parser MarkdownList +paragraphP = do + l <- lineTextP + ls <- A.many' $ (,[],) <$> lineEndP <* notP underlineP <* paragraphLineP <*> lineTextP + underline <- A.option [] $ (\br (lvl, u) -> [markupText HardBreak br, markupText (HeadingUnderline lvl) u]) <$> lineEndP <*> underlineP + pure $ inlineLines l ls <> underline + +underlineP :: Parser (Int, Text) +underlineP = lineTextP >>= \l -> maybe mzero (pure . (,l)) (underlineLevel l) + +inlineLines :: Text -> [(Text, MarkdownList, Text)] -> MarkdownList +inlineLines l0 nextLines = insertBreaks breaks $ intercalate ["\n"] $ map (parseMarkdownList . T.intercalate "\n") $ runs lineTexts + where + (lineTexts, breaks) = go l0 nextLines + go l = \case + (br, syntax, l') : more -> + let (l1, brk) = if isBlank l || isBlank l' then (l, markupText HardBreak br) else breakAfter l br + (ls, bs) = go l' more + in (l1 : ls, (brk : syntax) : bs) + [] -> ([l], []) + runs = \case + l : ls | not (isBlank l) -> let (run, ls') = break isBlank ls in (l : run) : runs ls' + l : ls -> [l] : runs ls + [] -> [] + +breakAfter :: Text -> Text -> (Text, FormattedText) +breakAfter l br + | T.length spaces >= 2 = (T.dropEnd (T.length spaces) l, markupText HardBreak (spaces <> br)) + | odd (T.length $ T.takeWhileEnd (== '\\') l) = (T.dropEnd 1 l, markupText HardBreak ("\\" <> br)) + | otherwise = (l, markupText SoftBreak br) + where + spaces = T.takeWhileEnd (== ' ') l + +insertBreaks :: [MarkdownList] -> MarkdownList -> MarkdownList +insertBreaks breaks = \case + FormattedText f t : fts -> case (T.breakOn "\n" t, breaks) of + ((t1, t2), brk : breaks') | not (T.null t2) -> segment f t1 <> brk <> insertBreaks breaks' (FormattedText f (T.drop 1 t2) : fts) + _ -> segment f t <> insertBreaks breaks fts + [] -> [] + where + segment f t = [FormattedText f t | isJust f || not (T.null t)] + +lineTextP :: Parser Text +lineTextP = T.concat <$> A.many' (A.takeWhile1 (\c -> c /= '\n' && c /= '\r') <|> (A.string "\r" <* (A.peekChar >>= guard . (/= Just '\n')))) + +lineEndP :: Parser Text +lineEndP = A.string "\r\n" <|> A.string "\n" + +isIndentChar :: Char -> Bool +isIndentChar c = c == ' ' || c == '\t' + +isBlank :: Text -> Bool +isBlank = T.all isSpace + +indentColumn :: Text -> Int +indentColumn = T.foldl' (\col c -> if c == '\t' then nextTabStop col else col + 1) 0 + +nextTabStop :: Int -> Int +nextTabStop col = (col `div` 4 + 1) * 4 + +dropColumns :: Int -> Text -> (Text, Text) +dropColumns n = go 0 + where + go col s + | col >= n = ("", s) + | otherwise = case T.uncons s of + Just (' ', s') -> first (T.cons ' ') $ go (col + 1) s' + Just ('\t', s') -> first (T.cons '\t') $ go (nextTabStop col) s' + _ -> ("", s) + +fenceLine :: Text -> Maybe (Int, Int, Char, Maybe Text) +fenceLine t = do + let (ind, s) = T.span isIndentChar t + ch <- T.find (\c -> c == '`' || c == '~') (T.take 1 s) + let ticks = T.length $ T.takeWhile (== ch) s + info = T.drop ticks s + guard $ ticks >= 3 && (ch == '~' || T.all (/= '`') info) + pure (indentColumn ind, ticks, ch, listToMaybe $ T.words info) + +closingFence :: Int -> Char -> Text -> Bool +closingFence n ch t = + let s = T.dropWhile isIndentChar t + ticks = T.length $ T.takeWhile (== ch) s + in ticks >= n && T.all isSpace (T.drop ticks s) + +headingClosing :: Text -> (Text, Text) +headingClosing c = + let trimmed = T.dropWhileEnd isIndentChar c + hashes = T.takeWhileEnd (== '#') trimmed + before = T.dropEnd (T.length hashes) trimmed + c' = T.dropWhileEnd isIndentChar before + in if not (T.null hashes) && T.any (not . isSpace) c' && T.any isIndentChar (T.takeEnd 1 before) + then (c', T.drop (T.length c') c) + else (c, "") + +underlineLevel :: Text -> Maybe Int +underlineLevel t = case T.uncons s of + Just ('=', _) | T.all (== '=') s -> Just 1 + Just ('-', _) | T.all (== '-') s -> Just 2 + _ -> Nothing + where + s = T.dropWhileEnd isSpace $ T.dropWhile isIndentChar t + +isRule :: Text -> Bool +isRule t = case T.uncons s of + Just (c, _) -> c `elem` ("-*_" :: String) && T.length s >= 3 && T.all (== c) s + Nothing -> False + where + s = T.filter (not . isIndentChar) t + +tableStart :: Text -> Text -> Maybe [TableAlign] +tableStart t d = do + guard $ T.any (== '|') t + aligns <- delimiterRow d + guard $ length (tableCells t) == length aligns + pure aligns + +delimiterRow :: Text -> Maybe [TableAlign] +delimiterRow t + | T.any (== '|') t = mapM cellAlign $ tableCells t + | otherwise = Nothing + where + cellAlign cell = + let s = T.strip cell + (left, s') = maybe (False, s) (True,) $ T.stripPrefix ":" s + (right, dashes) = maybe (False, s') (True,) $ T.stripSuffix ":" s' + in if not (T.null dashes) && T.all (== '-') dashes + then Just $ case (left, right) of + (True, True) -> TACenter + (False, True) -> TARight + (True, False) -> TALeft + (False, False) -> TANone + else Nothing + +tableCells :: Text -> [Text] +tableCells t = let (_, cells, _) = tableLine t in cells + +tableLine :: Text -> (Text, [Text], Text) +tableLine t = (ind <> lead, T.splitOn "|" body, trail) + where + (ind, s) = T.span isIndentChar t + (lead, s') = maybe ("", s) ("|",) $ T.stripPrefix "|" s + ws = T.takeWhileEnd isSpace s' + s'' = T.dropEnd (T.length ws) s' + (body, trail) = maybe (s'', ws) (,"|" <> ws) $ T.stripSuffix "|" s'' + parseUri :: ByteString -> Either Text U.URI parseUri s = case U.parseURI U.laxURIParserOptions s of Left e -> Left $ "Invalid URI: " <> tshow e @@ -473,10 +941,10 @@ markdownText :: FormattedText -> Text markdownText (FormattedText f_ t) = case f_ of Nothing -> t Just f -> case f of - Bold -> around '*' + Bold (BoolDef singleStar) -> if singleStar then around '*' else "**" <> t <> "**" Italic -> around '_' StrikeThrough -> around '~' - Snippet -> around '`' + Snippet -> codeSpan Secret -> around '#' Small -> "!- " <> t <> "!" Colored (FormatColor c) -> color c @@ -488,9 +956,16 @@ markdownText (FormattedText f_ t) = case f_ of Command _ -> t Email -> t Phone -> t + Escape -> t + Markup _ -> t Unknown _ -> t where around c = c `T.cons` t `T.snoc` c + codeSpan = + let ticks = T.replicate (1 + maximum (0 : map T.length (filter (T.isPrefixOf "`") $ T.group t))) "`" + pad = T.isPrefixOf "`" t || T.isSuffixOf "`" t || (T.isPrefixOf " " t && T.isSuffixOf " " t && not (T.all (== ' ') t)) + sp = if pad then " " else "" + in ticks <> sp <> t <> sp <> ticks color c = case colorStr c of Just cStr -> cStr <> t `T.snoc` '!' Nothing -> t @@ -514,7 +989,7 @@ displayNameTextP_ = (,"") <$> quoted '\'' <|> splitPunctuation <$> takeNameTill takeNameTill p = A.peekChar' >>= \c -> if refChar c then A.takeTill p else fail "invalid first character in display name" - quoted c = A.char c *> takeNameTill (== c) <* A.char c + quoted c = A.char c *> takeNameTill (\x -> x == c || x == '\n') <* A.char c refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' commandTextP :: Parser (Text, Text) @@ -533,11 +1008,24 @@ viewName s = if T.any isSpace s || maybe False (isPunctuation . snd) (T.unsnoc s $(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) +$(JQ.deriveJSON (enumJSON $ dropPrefix "TA") ''TableAlign) + +$(JQ.deriveJSON (sumTypeJSON fstToLower) ''BlockToken) + $(JQ.deriveToJSON (sumTypeJSON fstToLower) ''Format) instance FromJSON Format where parseJSON v = $(JQ.mkParseJSON (sumTypeJSON fstToLower) ''Format) v <|> pure (Unknown v) +$(JQ.deriveJSON defaultJSON ''SegmentRange) + +$(JQ.deriveJSON defaultJSON ''ListEntry) + +$(JQ.deriveToJSON (sumTypeJSON fstToLower) ''MarkdownBlock) + +instance FromJSON MarkdownBlock where + parseJSON v = $(JQ.mkParseJSON (sumTypeJSON fstToLower) ''MarkdownBlock) v <|> pure (UnknownBlock v) + $(JQ.deriveJSON defaultJSON ''FormattedText) $(JQ.deriveToJSON defaultJSON ''ParsedMarkdown) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 50035b55be..d298a1d3c1 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -263,6 +263,7 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem -- The key is a name used in the message text, used to look up CIMention. mentions :: Map MemberName CIMention, formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock), quotedItem :: Maybe (CIQuote c), reactions :: [CIReactionCount], file :: Maybe (CIFile d) @@ -611,10 +612,16 @@ data CIQuote (c :: ChatType) = CIQuote sharedMsgId :: Maybe SharedMsgId, -- Nothing for the messages from the old clients sentAt :: UTCTime, content :: MsgContent, - formattedText :: Maybe MarkdownList + formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock) } deriving (Show) +mkCIQuote :: CIQDirection c -> Maybe ChatItemId -> Maybe SharedMsgId -> UTCTime -> MsgContent -> CIQuote c +mkCIQuote chatDir itemId sharedMsgId sentAt content = CIQuote {chatDir, itemId, sharedMsgId, sentAt, content, formattedText, markdownBlocks} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown $ msgContentText content + quoteItemId :: CIQuote c -> Maybe ChatItemId quoteItemId CIQuote {itemId} = itemId @@ -1360,13 +1367,14 @@ data ChatItemVersion = ChatItemVersion { chatItemVersionId :: Int64, msgContent :: MsgContent, formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock), itemVersionTs :: UTCTime, createdAt :: UTCTime } deriving (Eq, Show) mkItemVersion :: ChatItem c d -> Maybe ChatItemVersion -mkItemVersion ChatItem {content, formattedText, meta} = version <$> ciMsgContent content +mkItemVersion ChatItem {content, formattedText, markdownBlocks, meta} = version <$> ciMsgContent content where CIMeta {itemId, itemTs, createdAt} = meta version mc = @@ -1374,6 +1382,7 @@ mkItemVersion ChatItem {content, formattedText, meta} = version <$> ciMsgContent { chatItemVersionId = itemId, msgContent = mc, formattedText, + markdownBlocks, itemVersionTs = itemTs, createdAt = createdAt } diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 085df84956..94b4f013ea 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -41,7 +41,7 @@ import Simplex.Chat import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands -import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri) +import Simplex.Chat.Markdown (parseUri, parsedMarkdown, sanitizeUri) import Simplex.Chat.Mobile.Badges import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared @@ -393,7 +393,7 @@ chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) chatParseMarkdown :: ByteString -> JSONByteString -chatParseMarkdown = J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 +chatParseMarkdown = J.encode . parsedMarkdown . safeDecodeUtf8 {-# INLINE chatParseMarkdown #-} chatParseServer :: ByteString -> JSONByteString diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b8909da1b3..5ad9107e6f 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -690,7 +690,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing where ciQuote :: Maybe ChatItemId -> CIQDirection c -> CIQuote c - ciQuote itemId dir = CIQuote dir itemId msgId sentAt content . parseMaybeMarkdownList $ msgContentText content + ciQuote itemId dir = mkCIQuote dir itemId msgId sentAt content getDirectChatItemQuote_ :: Int64 -> Bool -> IO (CIQuote 'CTDirect) getDirectChatItemQuote_ contactId userSent = do fmap ciQuoteDirect . maybeFirstRow fromOnly $ @@ -1119,7 +1119,9 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem = Nothing, reactions = [], file} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown itemText badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d ciMeta content status = @@ -1282,6 +1284,7 @@ safeToDirectItem currentTs itemId = \case content = CIInvalidJSON errorText, mentions = M.empty, formattedText = Nothing, + markdownBlocks = Nothing, quotedItem = Nothing, reactions = [], file = Nothing @@ -1628,6 +1631,7 @@ safeToGroupItem currentTs itemId = \case content = CIInvalidJSON errorText, mentions = M.empty, formattedText = Nothing, + markdownBlocks = Nothing, quotedItem = Nothing, reactions = [], file = Nothing @@ -1868,6 +1872,7 @@ safeToLocalItem currentTs itemId = \case content = CIInvalidJSON errorText, mentions = M.empty, formattedText = Nothing, + markdownBlocks = Nothing, quotedItem = Nothing, reactions = [], file = Nothing @@ -2302,7 +2307,7 @@ toDirectQuote qr@(_, _, _, _, quotedSent) = toQuote qr $ direction . unBI <$> qu toQuote :: QuoteRow -> Maybe (CIQDirection c) -> Maybe (CIQuote c) toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir = - CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) + mkCIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) @@ -2331,7 +2336,9 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem = toDirectQuote quoteRow, reactions = [], file} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown itemText badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d ciMeta content status = @@ -2423,7 +2430,9 @@ toGroupChatItem _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown itemText badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d ciMeta content status = @@ -2569,6 +2578,7 @@ updateDirectChatItem' db User {userId} contactId ci newContent edited live timed updatedChatItem :: ChatItem c d -> CIContent d -> Bool -> Bool -> Maybe CITimed -> UTCTime -> ChatItem c d updatedChatItem ci@ChatItem {meta = meta@CIMeta {itemStatus, itemEdited, itemTimed, itemLive}} newContent edited live timed_ currentTs = let newText = ciContentToText newContent + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown newText edited' = itemEdited || edited live' = (live &&) <$> itemLive timed' = case timed_ of @@ -2580,7 +2590,7 @@ updatedChatItem ci@ChatItem {meta = meta@CIMeta {itemStatus, itemEdited, itemTim let deleteAt' = addUTCTime (realToFrac ttl) currentTs in Just CITimed {ttl, deleteAt = Just deleteAt'} _ -> itemTimed - in ci {content = newContent, meta = meta {itemText = newText, itemEdited = edited', itemTimed = timed', itemLive = live'}, formattedText = parseMaybeMarkdownList newText} + in ci {content = newContent, meta = meta {itemText = newText, itemEdited = edited', itemTimed = timed', itemLive = live'}, formattedText, markdownBlocks} -- this function assumes that direct item with correct chat direction already exists, -- it should be checked before calling it @@ -2861,7 +2871,7 @@ updateGroupChatItemModerated db User {userId} GroupInfo {groupId} ci m@GroupMemb WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (deletedTs, groupMemberId, toContent, toText, currentTs, userId, groupId, itemId) - pure ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just deletedTs) m), editable = False, deletable = False}, formattedText = Nothing} + pure ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just deletedTs) m), editable = False, deletable = False}, formattedText = Nothing, markdownBlocks = Nothing} deleteMemberCIs :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO () deleteMemberCIs db User {userId} GroupInfo {groupId, membership} member = do @@ -2933,7 +2943,7 @@ updateGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci deletedTs = WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (DBCIBlockedByAdmin, deletedTs, currentTs, userId, groupId, itemId) - pure $ ci {meta = (meta ci) {itemDeleted = Just (CIBlockedByAdmin $ Just deletedTs), editable = False, deletable = False}, formattedText = Nothing} + pure $ ci {meta = (meta ci) {itemDeleted = Just (CIBlockedByAdmin $ Just deletedTs), editable = False, deletable = False}, formattedText = Nothing, markdownBlocks = Nothing} pattern DBCINotDeleted :: Int pattern DBCINotDeleted = 0 @@ -3370,8 +3380,8 @@ getChatItemVersions db itemId = do where toChatItemVersion :: (Int64, MsgContent, UTCTime, UTCTime) -> ChatItemVersion toChatItemVersion (chatItemVersionId, msgContent, itemVersionTs, createdAt) = - let formattedText = parseMaybeMarkdownList $ msgContentText msgContent - in ChatItemVersion {chatItemVersionId, msgContent, formattedText, itemVersionTs, createdAt} + let ParsedMarkdown formattedText markdownBlocks = parsedMarkdown $ msgContentText msgContent + in ChatItemVersion {chatItemVersionId, msgContent, formattedText, markdownBlocks, itemVersionTs, createdAt} directCIWithReactions :: DB.Connection -> Contact -> CChatItem 'CTDirect -> IO (CChatItem 'CTDirect) directCIWithReactions db ct cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of diff --git a/src/Simplex/Chat/Styled.hs b/src/Simplex/Chat/Styled.hs index 2dd1faa391..e1925ce835 100644 --- a/src/Simplex/Chat/Styled.hs +++ b/src/Simplex/Chat/Styled.hs @@ -70,7 +70,7 @@ sShow = plain . show sgr :: Format -> [SGR] sgr = \case - Bold -> [SetConsoleIntensity BoldIntensity] + Bold {} -> [SetConsoleIntensity BoldIntensity] Italic -> [SetUnderlining SingleUnderline, SetItalicized True] StrikeThrough -> [SetSwapForegroundBackground True] Colored (FormatColor c) -> [SetColor Foreground Vivid c] diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 18d2286135..650209eadd 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -2169,7 +2169,7 @@ viewGroupDescription GroupInfo {groupProfile = GroupProfile {description}} = maybe ["No welcome message!"] ((bold' "Welcome message:" :) . map plain . T.lines) description bold' :: String -> StyledString -bold' = styled Bold +bold' = styled (Bold $ BoolDef False) viewContactAliasUpdated :: Contact -> [StyledString] viewContactAliasUpdated ct@Contact {profile = LocalProfile {localAlias}} diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs index 80f7432da2..db184f5a8a 100644 --- a/src/Simplex/Chat/Web.hs +++ b/src/Simplex/Chat/Web.hs @@ -31,6 +31,7 @@ import Control.Monad import Control.Monad.Except (runExceptT) import Data.Either (rights) import Data.Int (Int64) +import Data.List.NonEmpty (NonEmpty) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B @@ -44,7 +45,7 @@ import qualified Data.Text as T import qualified Data.Text.IO as TIO import Data.Time.Clock (UTCTime, getCurrentTime) import Simplex.Chat.Controller (ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), storeCxt) -import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList) +import Simplex.Chat.Markdown (MarkdownBlock, MarkdownList, ParsedMarkdown (..), parsedMarkdown) import Simplex.Chat.Messages ( CChatItem (..), CIDirection (..), @@ -101,6 +102,7 @@ data WebMessage = WebMessage ts :: UTCTime, content :: MsgContent, formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock), file :: Maybe WebFileInfo, quote :: Maybe QuotedMsg, reactions :: [CIReactionCount], @@ -112,7 +114,9 @@ data WebMessage = WebMessage data WebChannelPreview = WebChannelPreview { channel :: GroupProfile, shortDescription :: Maybe MarkdownList, + shortDescriptionBlocks :: Maybe (NonEmpty MarkdownBlock), welcomeMessage :: Maybe MarkdownList, + welcomeMessageBlocks :: Maybe (NonEmpty MarkdownBlock), members :: [WebMemberProfile], subscribers :: Maybe Int64, messages :: [WebMessage], @@ -247,10 +251,14 @@ renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gI let rendered = mapMaybe toRenderedItem $ rights items msgs = map fst rendered senders = collectSenders $ map memberToProfile owners <> concatMap snd rendered + ParsedMarkdown shortDescription shortDescriptionBlocks = maybe (ParsedMarkdown Nothing Nothing) parsedMarkdown sd + ParsedMarkdown welcomeMessage welcomeMessageBlocks = maybe (ParsedMarkdown Nothing Nothing) parsedMarkdown wd preview = WebChannelPreview { channel = gp, - shortDescription = toFormattedText =<< sd, - welcomeMessage = toFormattedText =<< wd, + shortDescription, + shortDescriptionBlocks, + welcomeMessage, + welcomeMessageBlocks, members = senders, subscribers, messages = msgs, @@ -307,7 +315,7 @@ channelRemoved cc gId = void $ tryPutTMVar wakeSignal () toRenderedItem :: CChatItem 'CTGroup -> Maybe (WebMessage, [WebMemberProfile]) -toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, quotedItem, reactions, file}) +toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, markdownBlocks, quotedItem, reactions, file}) | isJust itemTimed = Nothing | otherwise = case ciMsgContent content of Just mc | not (isReport mc) -> @@ -323,6 +331,7 @@ toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, ts = itemTs, content = mc, formattedText, + markdownBlocks, file = webFileInfo <$> file, quote = quotedItem >>= ciQuoteToQuotedMsg, reactions, @@ -423,13 +432,6 @@ removeStaleFiles dir activeFiles = do removeFile (dir f) `catchOwn'` \(e :: SomeException) -> logError $ "web preview: error removing stale file " <> T.pack f <> ": " <> tshow e -toFormattedText :: Text -> Maybe MarkdownList -toFormattedText t = case parseMaybeMarkdownList t of - Just fts | any hasFormat fts -> Just fts - _ -> Nothing - where - hasFormat (FormattedText fmt _) = isJust fmt - publicGroupIdFileName :: B64UrlByteString -> String publicGroupIdFileName = B.unpack . strEncode diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 521fc44d9a..dcb3321702 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -8442,6 +8442,12 @@ testUpdatedMentionNames = do `shouldBe` "hello @'Alice Jones' @robert" test (mm [("alice", Just "alice"), ("cath", Just "alice")]) "hello @alice @cath" `shouldBe` "hello @alice @alice_1" + it "replace changed names outside code" $ \_ -> do + let t = "- hello\n| x | @bob |\n|---|---|\n```\n@bob\n```" + t' = "- hello\n| x | @robert |\n|---|---|\n```\n@bob\n```" + (mc', ft', _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) (mm [("bob", Just "robert")]) + msgContentText mc' `shouldBe` t' + ft' `shouldBe` parseMaybeMarkdownList t' where test mentionsMap t = let (mc', _, _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) mentionsMap diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index bbdf8c14b0..e7948ac190 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -38,7 +38,13 @@ userJSON :: LB.ByteString userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false}" parsedMarkdownSwift :: LB.ByteString -parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" +parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{\"singleStar\":true}},\"text\":\"hello\"}]}" parsedMarkdownTagged :: LB.ByteString -parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" +parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\",\"singleStar\":true},\"text\":\"hello\"}]}" + +markdownBlocksSwift :: LB.ByteString +markdownBlocksSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"markup\":{\"token\":{\"_owsf\":true,\"headingMark\":{\"level\":2}}}},\"text\":\"## \"},{\"text\":\"a\"},{\"format\":{\"_owsf\":true,\"markup\":{\"token\":{\"_owsf\":true,\"lineBreak\":{}}}},\"text\":\"\\n\"},{\"format\":{\"_owsf\":true,\"markup\":{\"token\":{\"_owsf\":true,\"listMark\":{}}}},\"text\":\"- \"},{\"format\":{\"_owsf\":true,\"bold\":{\"singleStar\":true}},\"text\":\"b\"}],\"markdownBlocks\":[{\"_owsf\":true,\"heading\":{\"level\":2,\"content\":{\"from\":1,\"to\":2}}},{\"_owsf\":true,\"list\":{\"items\":[{\"content\":{\"from\":4,\"to\":5},\"items\":[]}]}}]}" + +markdownBlocksTagged :: LB.ByteString +markdownBlocksTagged = "{\"formattedText\":[{\"format\":{\"type\":\"markup\",\"token\":{\"type\":\"headingMark\",\"level\":2}},\"text\":\"## \"},{\"text\":\"a\"},{\"format\":{\"type\":\"markup\",\"token\":{\"type\":\"lineBreak\"}},\"text\":\"\\n\"},{\"format\":{\"type\":\"markup\",\"token\":{\"type\":\"listMark\"}},\"text\":\"- \"},{\"format\":{\"type\":\"bold\",\"singleStar\":true},\"text\":\"b\"}],\"markdownBlocks\":[{\"type\":\"heading\",\"level\":2,\"content\":{\"from\":1,\"to\":2}},{\"type\":\"list\",\"items\":[{\"content\":{\"from\":4,\"to\":5},\"items\":[]}]}]}" diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index 52d722d26a..4f37e507f7 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -1,4 +1,5 @@ {-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} module JSONTests where @@ -13,7 +14,9 @@ import qualified Data.ByteString.Lazy.Char8 as LB import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) import JSONFixtures +import Simplex.Chat.Markdown (Format (Bold), MarkdownBlock (UnknownBlock), ParsedMarkdown (..), parsedMarkdown) import Simplex.Chat.Remote.Protocol (owsf2tagged) +import Simplex.Chat.Types (BoolDef (..)) import Simplex.Messaging.Parsers import Test.Hspec import Test.Hspec.QuickCheck (modifyMaxSuccess) @@ -27,6 +30,7 @@ owsf2TaggedJSONTest = do chatStartedSwift `to` chatStartedTagged connectionsDiffSwift `to` connectionsDiffTagged parsedMarkdownSwift `to` parsedMarkdownTagged + markdownBlocksSwift `to` markdownBlocksTagged where to :: LB.ByteString -> LB.ByteString -> IO () owsf `to` tagged = @@ -77,7 +81,23 @@ owsf2TaggedSomeTypeTests = modifyMaxSuccess (const 10000) $ do it "should convert to tagged via TH encoding" $ property $ \x -> (join . fmap (JT.parseMaybe thParseJSON . owsf2tagged) . J.decode . toLazyByteString . J.fromEncoding . thToEncoding) x == Just (x :: SomeType) +markdownBlocksJSONTests :: Spec +markdownBlocksJSONTests = do + it "should encode tokens and blocks" $ + J.encode (parsedMarkdown "## a\n- *b*") `shouldBe` markdownBlocksTagged + it "should decode tokens and blocks of each kind" $ do + let ParsedMarkdown ft blocks = parsedMarkdown "# a #\nb\n===\n- *c*\n 1. d\n> e\n\n| x | y |\n|:-|-:|\n| 1 |\n```bash\ncode\n```\n---\ne \nf\\\ng\n- \\*h\n i" + J.eitherDecode (J.encode ft) `shouldBe` Right ft + J.eitherDecode (J.encode blocks) `shouldBe` Right blocks + it "should decode an unknown block" $ + J.eitherDecode "{\"type\":\"future\"}" `shouldBe` Right (UnknownBlock $ J.object ["type" J..= ("future" :: String)]) + it "should decode bold without singleStar" $ do + J.eitherDecode "{\"type\":\"bold\"}" `shouldBe` Right (Bold $ BoolDef False) + (JT.parseEither J.parseJSON . owsf2tagged =<< J.eitherDecode "{\"_owsf\":true,\"bold\":{}}") `shouldBe` Right (Bold $ BoolDef False) + jsonTests :: Spec -jsonTests = describe "owsf2tagged" $ do - it "should convert chat types" owsf2TaggedJSONTest - describe "SomeType" owsf2TaggedSomeTypeTests +jsonTests = do + describe "owsf2tagged" $ do + it "should convert chat types" owsf2TaggedJSONTest + describe "SomeType" owsf2TaggedSomeTypeTests + describe "Markdown blocks" markdownBlocksJSONTests diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 903e3b3773..9c0de7e6e2 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -1,15 +1,22 @@ {-# LANGUAGE BlockArguments #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} module MarkdownTests where +import Control.Applicative ((<|>)) +import Control.Monad (forM_) +import Data.List (intercalate) import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L +import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Markdown +import Simplex.Chat.Types (BoolDef (..)) import Simplex.Messaging.Agent.Protocol (SimplexDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$$>)) @@ -32,6 +39,21 @@ markdownTests = do textWithCommands textWithSimplexNames multilineMarkdownList + inlineMarkdownGFM + backslashEscapes + textsWithoutBlocks + blockMarkdown + breaksInBlocks + headings + thematicBreaks + lists + quotes + codeBlocks + tables + formatsAcrossLines + liveMessages + markdownConsumers + linearTimeParsing testSanitizeUri infixr 1 ==>, <==, <==>, ==>>, <<==, <<==>> @@ -54,8 +76,17 @@ s <<== ft = T.concat (map markdownText ft) `shouldBe` s (<<==>>) :: Text -> MarkdownList -> Expectation s <<==>> ft = (s ==>> ft) >> (s <<== ft) +boldF :: Format +boldF = Bold $ BoolDef True + +bold2F :: Format +bold2F = Bold $ BoolDef False + bold :: Text -> Markdown -bold = markdown Bold +bold = markdown boldF + +bold2 :: Text -> Markdown +bold2 = markdown bold2F textFormat :: Spec textFormat = describe "text format (bold)" do @@ -76,17 +107,17 @@ textFormat = describe "text format (bold)" do <==> "this is " <> bold "bold" <> " " it "correct markdown with double asterisk" do "this is **bold formatted** text" - ==> "this is " <> bold "bold formatted" <> " text" + <==> "this is " <> bold2 "bold formatted" <> " text" "**bold formatted** text" - ==> bold "bold formatted" <> " text" + <==> bold2 "bold formatted" <> " text" "this is **bold**" - ==> "this is " <> bold "bold" + <==> "this is " <> bold2 "bold" " **bold** text" - ==> " " <> bold "bold" <> " text" + <==> " " <> bold2 "bold" <> " text" "this is **bold** " - ==> "this is " <> bold "bold" <> " " + <==> "this is " <> bold2 "bold" <> " " "this is **bold** " - ==> "this is " <> bold "bold" <> " " + <==> "this is " <> bold2 "bold" <> " " it "ignored as markdown" do "this is * unformatted * text" <==> "this is * unformatted * text" @@ -478,7 +509,576 @@ multilineMarkdownList = describe "multiline markdown" do it "command markdown" do "/link 1" <<==>> [command' "link 1" "/link 1"] " /link 1" <<==>> [command' "link 1" " /link 1"] - "*0*\n/7 /+" <<==>> [FormattedText (Just Bold) "0", "\n", command' "7" "/7", " ", command' "+" "/+"] + "*0*\n/7 /+" <<==>> [FormattedText (Just boldF) "0", "\n", command' "7" "/7", " ", command' "+" "/+"] + +inlineMarkdownGFM :: Spec +inlineMarkdownGFM = describe "inline markdown of GFM" do + it "strike-through and bold with double delimiters" do + "~~struck~~ text" ==> markdown StrikeThrough "struck" <> " text" + "~struck~ text" <==> markdown StrikeThrough "struck" <> " text" + "__bold__ text" ==> bold2 "bold" <> " text" + "**a**b" <==> bold2 "a" <> "b" + "~~ a~~" ==> "~~ a~~" + "__ a__" ==> "__ a__" + "a__b__" ==> "a__b__" + markdownText (fmt boldF "a") `shouldBe` "*a*" + markdownText (fmt bold2F "a") `shouldBe` "**a**" + it "code spans" do + "`` x ` y ``" ==> markdown Snippet "x ` y" + "``x ` y``" <== markdown Snippet "x ` y" + "` a `" ==> markdown Snippet "a" + "` a`" <==> markdown Snippet " a" + "` `" <==> markdown Snippet " " + "` a `" <==> markdown Snippet " a " + "``a``" ==> markdown Snippet "a" + "```a```" ==> markdown Snippet "a" + "``` a ```" ==> markdown Snippet "a" + "`` a`" <==> "`` a`" + "`a``b`" ==> markdown Snippet "a``b" + "```a``b```" <== markdown Snippet "a``b" + markdownText (fmt Snippet "`a") `shouldBe` "`` `a ``" + it "autolinks" do + "" <==> web "https://simplex.chat" "https://simplex.chat" "" + "mail " <==> "mail " <> web "chat@simplex.chat" "mailto:chat@simplex.chat" "" + "" <==> "" + " "" <==> "" + +backslashEscapes :: Spec +backslashEscapes = describe "backslash escapes" do + it "an escaped ASCII punctuation character is text" do + "\\*not bold*" <==> markdown Escape "\\" <> "*not bold*" + "a\\_b_" <==> "a" <> markdown Escape "\\" <> "_b_" + "\\\\" <==> markdown Escape "\\" <> "\\" + "\\`a`" <==> markdown Escape "\\" <> "`a`" + it "a backslash before other characters is text" do + "\\a" <==> "\\a" + "\\ a" <==> "\\ a" + "a\\" <==> "a\\" + "C:\\dir\\file.txt" <==> "C:\\dir\\file.txt" + it "escapes are text in code" do + "`\\*`" <==> markdown Snippet "\\*" + "```\n\\*a\n```" <===> ([fence "```", hardBr, "\\*a", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + it "escaped block syntax is text" do + "\\# a\n- b" <===> ([fmt Escape "\\", "# a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 2), List [entry 4 5 []]]) + "\\- a\n- b" <===> ([fmt Escape "\\", "- a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 2), List [entry 4 5 []]]) + "\\> a\n- b" <===> ([fmt Escape "\\", "> a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 2), List [entry 4 5 []]]) + "1\\. a\n- b" <===> (["1", fmt Escape "\\", ". a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + it "escapes are kept in texts without blocks" do + "\\*a*" <<==>> [fmt Escape "\\", "*a*"] + "\\*a*\nb" <<==>> [fmt Escape "\\", "*a*\nb"] + +textsWithoutBlocks :: Spec +textsWithoutBlocks = describe "texts without blocks, as on master" do + it "no format" do + parseMaybeMarkdownList "a\nb" `shouldBe` Nothing + parseMaybeMarkdownList "a\n\nb" `shouldBe` Nothing + parseMaybeMarkdownList "a\n" `shouldBe` Nothing + parseMaybeMarkdownList "a \nb" `shouldBe` Nothing + parseMaybeMarkdownList "\n\na" `shouldBe` Nothing + it "line breaks are text" do + "*a*\n\nb" <<==>> [fmt boldF "a", "\n\nb"] + "*a*\nb" <<==>> [fmt boldF "a", "\nb"] + "*a*\r\nb" <<==>> [fmt boldF "a", "\r\nb"] + "*a* \nb" <<==>> [fmt boldF "a", " \nb"] + "*a*\\\nb" <<==>> [fmt boldF "a", "\\\nb"] + " a\n*b*" <<==>> [" a\n", fmt boldF "b"] + "\n\n*a*" <<==>> ["\n\n", fmt boldF "a"] + it "the final line break is removed" do + "*a*\n" ==>> [fmt boldF "a"] + "*a*\r\n" ==>> [fmt boldF "a"] + "*a*\n\n" ==>> [fmt boldF "a", "\n"] + +infix 1 ===>, <===> + +(===>) :: Text -> (MarkdownList, [MarkdownBlock]) -> Expectation +s ===> (ft, blocks) = do + let ParsedMarkdown ft' blocks' = parsedMarkdown s + (ft', blocks') `shouldBe` (Just ft, L.nonEmpty blocks) + rangesCoverContent ft blocks + +(<===>) :: Text -> (MarkdownList, [MarkdownBlock]) -> Expectation +s <===> parsed@(ft, _) = (s ===> parsed) >> (withoutFinalBreak <<== ft) + where + withoutFinalBreak = fromMaybe s $ T.stripSuffix "\r\n" s <|> T.stripSuffix "\n" s + +noBlocks :: Text -> Expectation +noBlocks s = let ParsedMarkdown _ blocks = parsedMarkdown s in blocks `shouldBe` Nothing + +rangesCoverContent :: MarkdownList -> [MarkdownBlock] -> Expectation +rangesCoverContent ft blocks = do + ranges `shouldSatisfy` all (\(SegmentRange a b) -> 0 <= a && a <= b && b <= length ft) + zip ranges (drop 1 ranges) `shouldSatisfy` all (\(SegmentRange _ b, SegmentRange a _) -> b <= a) + [i | SegmentRange a b <- ranges, i <- [a .. b - 1], i `elem` contentIndices] `shouldBe` contentIndices + where + ranges = concatMap blockRanges blocks + contentIndices = [i | (i, FormattedText f _) <- zip [0 ..] ft, not (isMarkup f)] + blockRanges = \case + Paragraph r -> [r] + Heading _ r -> [r] + CodeBlock _ r -> [r] + Quote r -> [r] + List entries -> concatMap entryRanges entries + Table _ headerCells bodyRows -> headerCells <> concat bodyRows + Rule r -> [r] + UnknownBlock _ -> [] + entryRanges (ListEntry _ r children) = r : concatMap entryRanges children + +fmt :: Format -> Text -> FormattedText +fmt = FormattedText . Just + +mk :: BlockToken -> Text -> FormattedText +mk = markupText + +lineBr :: FormattedText +lineBr = mk LineBreak "\n" + +softBr :: FormattedText +softBr = mk SoftBreak "\n" + +hardBr :: FormattedText +hardBr = mk HardBreak "\n" + +heading1 :: FormattedText +heading1 = mk (HeadingMark 1) "# " + +bullet :: Text -> FormattedText +bullet = mk (ListMark Nothing) + +numbered :: Text -> FormattedText +numbered n = mk (ListMark $ Just n) (n <> " ") + +quoteMark :: Text -> FormattedText +quoteMark = mk QuoteMark + +fence :: Text -> FormattedText +fence = mk (FenceMark Nothing) + +rng :: Int -> Int -> SegmentRange +rng = SegmentRange + +entry :: Int -> Int -> [ListEntry] -> ListEntry +entry a b = ListEntry Nothing (rng a b) + +blockMarkdown :: Spec +blockMarkdown = describe "block markdown" do + it "blocks and their ranges" do + "# Plan\n- buy *milk*\n and eggs\n - nested\n\n> a\n> b" + <===> ( [ mk (HeadingMark 1) "# ", + "Plan", + mk LineBreak "\n", + mk (ListMark Nothing) "- ", + "buy ", + FormattedText (Just boldF) "milk", + mk SoftBreak "\n", + mk Indent " ", + "and eggs", + mk LineBreak "\n", + mk (ListMark Nothing) " - ", + "nested", + mk (ParagraphBreak 2) "\n\n", + mk QuoteMark "> ", + "a", + mk SoftBreak "\n", + mk QuoteMark "> ", + "b" + ], + [Heading 1 (rng 1 2), List [entry 4 9 [entry 11 12 []]], Quote (rng 14 18)] + ) + +breaksInBlocks :: Spec +breaksInBlocks = describe "line breaks in texts with blocks" do + it "soft breaks in paragraphs, list items and quotes" do + "a\nb\nc\n# d" <===> (["a", softBr, "b", softBr, "c", lineBr, heading1, "d"], [Paragraph (rng 0 5), Heading 1 (rng 7 8)]) + " a\n b\n# c" <===> ([" a", softBr, " b", lineBr, heading1, "c"], [Paragraph (rng 0 3), Heading 1 (rng 5 6)]) + "- a\nb" <===> ([bullet "- ", "a", softBr, "b"], [List [entry 1 4 []]]) + "> a\nb" <===> ([quoteMark "> ", "a", softBr, "b"], [Quote (rng 1 4)]) + it "hard breaks by two or more spaces and by a backslash" do + "a \nb\n- c" <===> (["a", mk HardBreak " \n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a \nb\n- c" <===> (["a", mk HardBreak " \n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a\\\nb\n- c" <===> (["a", mk HardBreak "\\\n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a\\\\\\\nb\n- c" <===> (["a", fmt Escape "\\", "\\", mk HardBreak "\\\n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 5), List [entry 7 8 []]]) + "- a \n b" <===> ([bullet "- ", "a", mk HardBreak " \n", mk Indent " ", "b"], [List [entry 1 5 []]]) + "- a\\\n b" <===> ([bullet "- ", "a", mk HardBreak "\\\n", mk Indent " ", "b"], [List [entry 1 5 []]]) + "> a \n> b" <===> ([quoteMark "> ", "a", mk HardBreak " \n", quoteMark "> ", "b"], [Quote (rng 1 5)]) + "> a\\\n> b" <===> ([quoteMark "> ", "a", mk HardBreak "\\\n", quoteMark "> ", "b"], [Quote (rng 1 5)]) + it "spaces and backslashes that are content" do + "a \nb\n- c" <===> (["a ", softBr, "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a\\\\\nb\n- c" <===> (["a", fmt Escape "\\", "\\", softBr, "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 5), List [entry 7 8 []]]) + "a \n\n- c" <===> (["a ", mk (ParagraphBreak 2) "\n\n", bullet "- ", "c"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "a\\\n\n- c" <===> (["a\\", mk (ParagraphBreak 2) "\n\n", bullet "- ", "c"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "a \n- c" <===> (["a ", lineBr, bullet "- ", "c"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "# h\na " <===> ([heading1, "h", lineBr, "a "], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "# h\na\\" <===> ([heading1, "h", lineBr, "a\\"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + it "line and paragraph breaks between blocks" do + "## a\nb" <===> ([mk (HeadingMark 2) "## ", "a", lineBr, "b"], [Heading 2 (rng 1 2), Paragraph (rng 3 4)]) + "# a\n\nb" <===> ([heading1, "a", mk (ParagraphBreak 2) "\n\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "# a\n\n\nb" <===> ([heading1, "a", mk (ParagraphBreak 3) "\n\n\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "# a\n \n\t\nb" <===> ([heading1, "a", mk (ParagraphBreak 3) "\n \n\t\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + it "blank lines at the text start and end" do + "\n\n# a" <===> ([mk (ParagraphBreak 2) "\n\n", heading1, "a"], [Heading 1 (rng 2 3)]) + "\n \n# a" <===> ([mk (ParagraphBreak 2) "\n \n", heading1, "a"], [Heading 1 (rng 2 3)]) + "# a\n\n" <===> ([heading1, "a", lineBr], [Heading 1 (rng 1 2)]) + "# a\n\n\n" <===> ([heading1, "a", mk (ParagraphBreak 2) "\n\n"], [Heading 1 (rng 1 2)]) + "# a\n " <===> ([heading1, "a", mk (ParagraphBreak 1) "\n "], [Heading 1 (rng 1 2)]) + it "CR before line breaks" do + "a\r\nb\r\n- c" <===> (["a", mk SoftBreak "\r\n", "b", mk LineBreak "\r\n", bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a \r\nb\r\n- c" <===> (["a", mk HardBreak " \r\n", "b", mk LineBreak "\r\n", bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "# a\r\n- b" <===> ([heading1, "a", mk LineBreak "\r\n", bullet "- ", "b"], [Heading 1 (rng 1 2), List [entry 4 5 []]]) + "# a\r\n\r\nb" <===> ([heading1, "a", mk (ParagraphBreak 2) "\r\n\r\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "- a\r\n b" <===> ([bullet "- ", "a", mk SoftBreak "\r\n", mk Indent " ", "b"], [List [entry 1 5 []]]) + "> a\r\n> b" <===> ([quoteMark "> ", "a", mk SoftBreak "\r\n", quoteMark "> ", "b"], [Quote (rng 1 5)]) + "a\r\n===" <===> (["a", mk HardBreak "\r\n", mk (HeadingUnderline 1) "==="], [Heading 1 (rng 0 1)]) + "```\r\na\r\n```" <===> ([fence "```", mk HardBreak "\r\n", "a", mk HardBreak "\r\n", fence "```"], [CodeBlock Nothing (rng 2 3)]) + "|a|\r\n|-|\r\n|b|" <===> ([mk RowMark "|", "a", mk CellMark "|", mk HardBreak "\r\n", mk (DelimiterRow [TANone]) "|-|", mk HardBreak "\r\n", mk RowMark "|", "b", mk CellMark "|"], [Table [TANone] [rng 1 2] [[rng 7 8]]]) + +headings :: Spec +headings = describe "headings" do + it "levels 1 to 6" do + forM_ ([1 .. 6] :: [Int]) $ \n -> do + let hashes = T.replicate n "#" + (hashes <> " a") <===> ([mk (HeadingMark n) (hashes <> " "), "a"], [Heading n (rng 1 2)]) + noBlocks "####### a" + it "a space and text after the marker" do + noBlocks "#" + noBlocks "# " + noBlocks "#a" + noBlocks "#\ta" + noBlocks "#\tx#" + "#\n- b" <===> (["#", lineBr, bullet "- ", "b"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + it "indentation and inline content" do + " # a" <===> ([mk (HeadingMark 1) " # ", "a"], [Heading 1 (rng 1 2)]) + "# *a* b" <===> ([heading1, fmt boldF "a", " b"], [Heading 1 (rng 1 3)]) + it "closing sequences" do + "# a #" <===> ([heading1, "a", mk HeadingClose " #"], [Heading 1 (rng 1 2)]) + "# a ## " <===> ([heading1, "a", mk HeadingClose " ## "], [Heading 1 (rng 1 2)]) + "# a #\nb" <===> ([heading1, "a", mk HeadingClose " #", lineBr, "b"], [Heading 1 (rng 1 2), Paragraph (rng 4 5)]) + "# a#" <===> ([heading1, "a#"], [Heading 1 (rng 1 2)]) + "# a \\#" <===> ([heading1, "a ", fmt Escape "\\", "#"], [Heading 1 (rng 1 4)]) + it "one line, after and before other blocks" do + "a\n# b" <===> (["a", lineBr, heading1, "b"], [Paragraph (rng 0 1), Heading 1 (rng 3 4)]) + "# a\n# b" <===> ([heading1, "a", lineBr, heading1, "b"], [Heading 1 (rng 1 2), Heading 1 (rng 4 5)]) + it "setext headings" do + "a\n===" <===> (["a", hardBr, mk (HeadingUnderline 1) "==="], [Heading 1 (rng 0 1)]) + "a\n---" <===> (["a", hardBr, mk (HeadingUnderline 2) "---"], [Heading 2 (rng 0 1)]) + "a\n-" <===> (["a", hardBr, mk (HeadingUnderline 2) "-"], [Heading 2 (rng 0 1)]) + "a\nb\n=" <===> (["a", softBr, "b", hardBr, mk (HeadingUnderline 1) "="], [Heading 1 (rng 0 3)]) + "a\nb\n---" <===> (["a", softBr, "b", hardBr, mk (HeadingUnderline 2) "---"], [Heading 2 (rng 0 3)]) + "a\n === " <===> (["a", hardBr, mk (HeadingUnderline 1) " === "], [Heading 1 (rng 0 1)]) + "a\n---\nb" <===> (["a", hardBr, mk (HeadingUnderline 2) "---", lineBr, "b"], [Heading 2 (rng 0 1), Paragraph (rng 4 5)]) + it "underlines only under paragraphs" do + "a\n- - -" <===> (["a", lineBr, mk RuleMark "- - -"], [Paragraph (rng 0 1), Rule (rng 2 3)]) + "a\n***" <===> (["a", lineBr, mk RuleMark "***"], [Paragraph (rng 0 1), Rule (rng 2 3)]) + "# a\n===" <===> ([heading1, "a", lineBr, "==="], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "> a\n===" <===> ([quoteMark "> ", "a", softBr, "==="], [Quote (rng 1 4)]) + "- a\n===" <===> ([bullet "- ", "a", softBr, "==="], [List [entry 1 4 []]]) + noBlocks "a\n= =" + noBlocks "a\n\n===" + noBlocks "===" + noBlocks "===\na" + +thematicBreaks :: Spec +thematicBreaks = describe "thematic breaks" do + it "3 or more of one character, with spaces between and around" do + forM_ (["---", "***", "___", "- - -", "* * *", "_ _ _", "-- -", " ***", "*** ", "----------"] :: [Text]) $ \r -> + r <===> ([mk RuleMark r], [Rule (rng 0 1)]) + it "other lines are text" do + noBlocks "--" + noBlocks "*-*" + noBlocks "---x" + noBlocks "-- a" + it "between other blocks" do + "a\n***\nb" <===> (["a", lineBr, mk RuleMark "***", lineBr, "b"], [Paragraph (rng 0 1), Rule (rng 2 3), Paragraph (rng 4 5)]) + "- a\n---" <===> ([bullet "- ", "a", lineBr, mk RuleMark "---"], [List [entry 1 2 []], Rule (rng 3 4)]) + +lists :: Spec +lists = describe "lists" do + it "bullet markers" do + forM_ (["- ", "* ", "+ "] :: [Text]) $ \m -> (m <> "a") <===> ([bullet m, "a"], [List [entry 1 2 []]]) + "- a\n* b\n+ c" <===> ([bullet "- ", "a", lineBr, bullet "* ", "b", lineBr, bullet "+ ", "c"], [List [entry 1 2 [], entry 4 5 [], entry 7 8 []]]) + it "numbers as written" do + "1. a\n2) b" <===> ([numbered "1.", "a", lineBr, numbered "2)", "b"], [List [ListEntry (Just "1.") (rng 1 2) [], ListEntry (Just "2)") (rng 4 5) []]]) + "3. a\n7. b" <===> ([numbered "3.", "a", lineBr, numbered "7.", "b"], [List [ListEntry (Just "3.") (rng 1 2) [], ListEntry (Just "7.") (rng 4 5) []]]) + "123456789. a" <===> ([numbered "123456789.", "a"], [List [ListEntry (Just "123456789.") (rng 1 2) []]]) + it "markers without a space and text are text" do + noBlocks "-a" + noBlocks "- " + noBlocks "1.a" + noBlocks "1. " + noBlocks "1234567890. a" + noBlocks "a. b" + it "nesting by the column of the marker" do + "- a\n - b" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b"], [List [entry 1 2 [entry 4 5 []]]]) + "1. a\n - b" <===> ([numbered "1.", "a", lineBr, bullet " - ", "b"], [List [ListEntry (Just "1.") (rng 1 2) [entry 4 5 []]]]) + "- a\n - b" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b"], [List [entry 1 2 [entry 4 5 []]]]) + "- a\n\t- b" <===> ([bullet "- ", "a", lineBr, bullet "\t- ", "b"], [List [entry 1 2 [entry 4 5 []]]]) + "- a\n - b\n- c" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b", lineBr, bullet "- ", "c"], [List [entry 1 2 [entry 4 5 []], entry 7 8 []]]) + "- a\n - b\n - c" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b", lineBr, bullet " - ", "c"], [List [entry 1 2 [entry 4 5 [], entry 7 8 []]]]) + " - a\n- b" <===> ([bullet " - ", "a", lineBr, bullet "- ", "b"], [List [entry 1 2 [], entry 4 5 []]]) + it "depth at most 7" do + let ks = [1 .. 8] :: [Int] + marker k = T.replicate (2 * (k - 1)) " " <> "- " + e k = entry (3 * k - 2) (3 * k - 1) + T.intercalate "\n" [marker k <> T.pack (show k) | k <- ks] + <===> ( intercalate [lineBr] [[bullet (marker k), FormattedText Nothing (T.pack $ show k)] | k <- ks], + [List [e 1 [e 2 [e 3 [e 4 [e 5 [e 6 [e 7 [], e 8 []]]]]]]]] + ) + it "continuation lines" do + "- a\n b" <===> ([bullet "- ", "a", softBr, mk Indent " ", "b"], [List [entry 1 5 []]]) + "- a\n b" <===> ([bullet "- ", "a", softBr, mk Indent " ", "b"], [List [entry 1 5 []]]) + "- a\n\tb" <===> ([bullet "- ", "a", softBr, mk Indent "\t", "b"], [List [entry 1 5 []]]) + "- a\nb" <===> ([bullet "- ", "a", softBr, "b"], [List [entry 1 4 []]]) + "- a\n b\n - c" <===> ([bullet "- ", "a", softBr, mk Indent " ", "b", lineBr, bullet " - ", "c"], [List [entry 1 5 [entry 7 8 []]]]) + "- *a* b" <===> ([bullet "- ", fmt boldF "a", " b"], [List [entry 1 3 []]]) + it "blank lines between items" do + "- a\n\n- b" <===> ([bullet "- ", "a", mk (ParagraphBreak 2) "\n\n", bullet "- ", "b"], [List [entry 1 2 [], entry 4 5 []]]) + "- a\n\n\n- b" <===> ([bullet "- ", "a", mk (ParagraphBreak 3) "\n\n\n", bullet "- ", "b"], [List [entry 1 2 [], entry 4 5 []]]) + it "lists and other blocks" do + "- a\n\nb" <===> ([bullet "- ", "a", mk (ParagraphBreak 2) "\n\n", "b"], [List [entry 1 2 []], Paragraph (rng 3 4)]) + "a\n- b" <===> (["a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "# a\n- b" <===> ([heading1, "a", lineBr, bullet "- ", "b"], [Heading 1 (rng 1 2), List [entry 4 5 []]]) + "- a\n# b" <===> ([bullet "- ", "a", lineBr, heading1, "b"], [List [entry 1 2 []], Heading 1 (rng 4 5)]) + "- a\n> b" <===> ([bullet "- ", "a", lineBr, quoteMark "> ", "b"], [List [entry 1 2 []], Quote (rng 4 5)]) + "- a\n# h\n- b" <===> ([bullet "- ", "a", lineBr, heading1, "h", lineBr, bullet "- ", "b"], [List [entry 1 2 []], Heading 1 (rng 4 5), List [entry 7 8 []]]) + +quotes :: Spec +quotes = describe "quotes" do + it "quote marks" do + "> a" <===> ([quoteMark "> ", "a"], [Quote (rng 1 2)]) + ">a" <===> ([quoteMark ">", "a"], [Quote (rng 1 2)]) + ">\ta" <===> ([quoteMark ">\t", "a"], [Quote (rng 1 2)]) + "> a" <===> ([quoteMark "> ", " a"], [Quote (rng 1 2)]) + " > a" <===> ([quoteMark " > ", "a"], [Quote (rng 1 2)]) + ">a\n> b" <===> ([quoteMark ">", "a", softBr, quoteMark "> ", "b"], [Quote (rng 1 5)]) + it "quote marks without text are text" do + noBlocks ">" + noBlocks "> " + noBlocks ">\n>" + it "block syntax inside a quote is text" do + "> > b\nc" <===> ([quoteMark "> ", "> b", softBr, "c"], [Quote (rng 1 4)]) + "> # a" <===> ([quoteMark "> ", "# a"], [Quote (rng 1 2)]) + "> - a" <===> ([quoteMark "> ", "- a"], [Quote (rng 1 2)]) + it "blank quote lines" do + "> a\n>\n> b" <===> ([quoteMark "> ", "a", hardBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Quote (rng 1 7)]) + "> a\n>\n>\n> b" <===> ([quoteMark "> ", "a", hardBr, quoteMark ">", hardBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Quote (rng 1 9)]) + "> a\n> \n> b" <===> ([quoteMark "> ", "a", hardBr, quoteMark "> ", hardBr, quoteMark "> ", "b"], [Quote (rng 1 7)]) + ">\n> a\n>" <===> ([quoteMark ">", hardBr, quoteMark "> ", "a", hardBr, quoteMark ">"], [Quote (rng 3 4)]) + it "lines without quote marks after a line with text" do + "> a\nb" <===> ([quoteMark "> ", "a", softBr, "b"], [Quote (rng 1 4)]) + "> a\nb\n> c" <===> ([quoteMark "> ", "a", softBr, "b", softBr, quoteMark "> ", "c"], [Quote (rng 1 7)]) + "> a\nb\n>\n> c" <===> ([quoteMark "> ", "a", softBr, "b", hardBr, quoteMark ">", hardBr, quoteMark "> ", "c"], [Quote (rng 1 9)]) + "> a\n>\nb" <===> ([quoteMark "> ", "a", hardBr, quoteMark ">", lineBr, "b"], [Quote (rng 1 2), Paragraph (rng 5 6)]) + it "quotes and other blocks" do + "> a\n\nb" <===> ([quoteMark "> ", "a", mk (ParagraphBreak 2) "\n\n", "b"], [Quote (rng 1 2), Paragraph (rng 3 4)]) + "> a\n\n> b" <===> ([quoteMark "> ", "a", mk (ParagraphBreak 2) "\n\n", quoteMark "> ", "b"], [Quote (rng 1 2), Quote (rng 4 5)]) + "> a\n- b" <===> ([quoteMark "> ", "a", lineBr, bullet "- ", "b"], [Quote (rng 1 2), List [entry 4 5 []]]) + "> a\n# b" <===> ([quoteMark "> ", "a", lineBr, heading1, "b"], [Quote (rng 1 2), Heading 1 (rng 4 5)]) + "> a\nb | c\n-|-" + <===> ( [quoteMark "> ", "a", lineBr, mk RowMark "", "b", mk CellMark " | ", "c", mk CellMark "", hardBr, mk (DelimiterRow [TANone, TANone]) "-|-"], + [Quote (rng 1 2), Table [TANone, TANone] [rng 4 5, rng 6 7] []] + ) + it "quotes after paragraph lines" do + "a\n> b" <===> (["a", lineBr, quoteMark "> ", "b"], [Paragraph (rng 0 1), Quote (rng 3 4)]) + "a\n>\n> b" <===> (["a", lineBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Paragraph (rng 0 1), Quote (rng 5 6)]) + "a\n>\n>\n> b" <===> (["a", softBr, ">", lineBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Paragraph (rng 0 3), Quote (rng 7 8)]) + parseMaybeMarkdownList (T.intercalate "\n" $ "a" : replicate 7000 ">") `shouldBe` Nothing + +codeBlocks :: Spec +codeBlocks = describe "code blocks" do + it "fences and languages" do + "```\nx *y*\n```" <===> ([fence "```", hardBr, "x *y*", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + "~~~\na\n\nb\n~~~" <===> ([fence "~~~", hardBr, "a", hardBr, hardBr, "b", hardBr, fence "~~~"], [CodeBlock Nothing (rng 2 6)]) + "```bash\necho 1" <===> ([mk (FenceMark $ Just "bash") "```bash", hardBr, "echo 1"], [CodeBlock (Just "bash") (rng 2 3)]) + "```bash script\nx\n```" <===> ([mk (FenceMark $ Just "bash") "```bash script", hardBr, "x", hardBr, fence "```"], [CodeBlock (Just "bash") (rng 2 3)]) + "``` bash\nx\n```" <===> ([mk (FenceMark $ Just "bash") "``` bash", hardBr, "x", hardBr, fence "```"], [CodeBlock (Just "bash") (rng 2 3)]) + "~~~ a`b\nc\n~~~" <===> ([mk (FenceMark $ Just "a`b") "~~~ a`b", hardBr, "c", hardBr, fence "~~~"], [CodeBlock (Just "a`b") (rng 2 3)]) + noBlocks "``` a`b\nc\n```" + it "closing fences" do + "```\na\n~~~\nb\n```" <===> ([fence "```", hardBr, "a", hardBr, "~~~", hardBr, "b", hardBr, fence "```"], [CodeBlock Nothing (rng 2 7)]) + "```\na\n``` " <===> ([fence "```", hardBr, "a", hardBr, fence "``` "], [CodeBlock Nothing (rng 2 3)]) + "```\na\n`````" <===> ([fence "```", hardBr, "a", hardBr, fence "`````"], [CodeBlock Nothing (rng 2 3)]) + "````\na\n```\n````" <===> ([fence "````", hardBr, "a", hardBr, "```", hardBr, fence "````"], [CodeBlock Nothing (rng 2 5)]) + "```\na\n``` b" <===> ([fence "```", hardBr, "a", hardBr, "``` b"], [CodeBlock Nothing (rng 2 5)]) + it "blank and indented code lines" do + "```\n\na\n\n```" <===> ([fence "```", hardBr, hardBr, "a", hardBr, hardBr, fence "```"], [CodeBlock Nothing (rng 2 5)]) + "```\na\n\n```" <===> ([fence "```", hardBr, "a", hardBr, hardBr, fence "```"], [CodeBlock Nothing (rng 2 4)]) + " ```\n a\n ```" <===> ([fence " ```", hardBr, mk Indent " ", " a", hardBr, fence " ```"], [CodeBlock Nothing (rng 2 4)]) + " ```\n\ta\n ```" <===> ([fence " ```", hardBr, mk Indent "\t", "a", hardBr, fence " ```"], [CodeBlock Nothing (rng 2 4)]) + " ```\n a\n```" <===> ([fence " ```", hardBr, mk Indent " ", "a", hardBr, fence "```"], [CodeBlock Nothing (rng 2 4)]) + it "code is text" do + "```\n*a* `b` @c https://simplex.chat\n```" <===> ([fence "```", hardBr, "*a* `b` @c https://simplex.chat", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + "```\na \nb\\\n```" <===> ([fence "```", hardBr, "a ", hardBr, "b\\", hardBr, fence "```"], [CodeBlock Nothing (rng 2 5)]) + it "a fence followed by a line is a code block" do + "```\n```" <===> ([fence "```", hardBr, fence "```"], [CodeBlock Nothing (rng 2 2)]) + "```\n```\nx" <===> ([fence "```", hardBr, fence "```", lineBr, "x"], [CodeBlock Nothing (rng 2 2), Paragraph (rng 4 5)]) + "```\n\n```" <===> ([fence "```", hardBr, hardBr, fence "```"], [CodeBlock Nothing (rng 2 2)]) + "```\n \n```" <===> ([fence "```", hardBr, " ", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + "```\n\n" <===> ([fence "```", hardBr], [CodeBlock Nothing (rng 2 2)]) + it "a fence line at the text end is text" do + noBlocks "```" + noBlocks "```\n" + noBlocks "a\n```" + "a\n```\nb" <===> (["a", lineBr, fence "```", hardBr, "b"], [Paragraph (rng 0 1), CodeBlock Nothing (rng 4 5)]) + it "code blocks and other blocks" do + "a\n```\nb\n```" <===> (["a", lineBr, fence "```", hardBr, "b", hardBr, fence "```"], [Paragraph (rng 0 1), CodeBlock Nothing (rng 4 5)]) + "```\na\n```\nb" <===> ([fence "```", hardBr, "a", hardBr, fence "```", lineBr, "b"], [CodeBlock Nothing (rng 2 3), Paragraph (rng 6 7)]) + "- a\n```\nb\n```" <===> ([bullet "- ", "a", lineBr, fence "```", hardBr, "b", hardBr, fence "```"], [List [entry 1 2 []], CodeBlock Nothing (rng 5 6)]) + +tables :: Spec +tables = describe "tables" do + it "alignments" do + "| a | b | c | d |\n|---|:--|--:|:-:|" + <===> ( [mk RowMark "| ", "a", mk CellMark " | ", "b", mk CellMark " | ", "c", mk CellMark " | ", "d", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TALeft, TARight, TACenter]) "|---|:--|--:|:-:|"], + [Table [TANone, TALeft, TARight, TACenter] [rng 1 2, rng 3 4, rng 5 6, rng 7 8] []] + ) + "a | b\n:- | -:" <===> ([mk RowMark "", "a", mk CellMark " | ", "b", mk CellMark "", hardBr, mk (DelimiterRow [TALeft, TARight]) ":- | -:"], [Table [TALeft, TARight] [rng 1 2, rng 3 4] []]) + it "rows and cells" do + "| a | b |\n|---|:-:|\n| 1 | 2 |" + <===> ( [mk RowMark "| ", "a", mk CellMark " | ", "b", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TACenter]) "|---|:-:|", hardBr, mk RowMark "| ", "1", mk CellMark " | ", "2", mk CellMark " |"], + [Table [TANone, TACenter] [rng 1 2, rng 3 4] [[rng 9 10, rng 11 12]]] + ) + "|a|b|\n|-|-|\n|c|" <===> (header2 <> [hardBr, mk RowMark "|", "c", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 10]]]) + "|a|b|\n|-|-|\n|c|d|e|" <===> (header2 <> [hardBr, mk RowMark "|", "c", mk CellMark "|", "d|e", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 10, rng 11 12]]]) + "|a|b|\n|-|-|\n|c|d|e" <===> (header2 <> [hardBr, mk RowMark "|", "c", mk CellMark "|", "d|e", mk CellMark ""], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 10, rng 11 12]]]) + "|a|b|\n|-|-|\n||c|" <===> (header2 <> [hardBr, mk RowMark "|", mk CellMark "|", "c", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 9, rng 10 11]]]) + "|a|b|\n|-|-|\n| |c|" <===> (header2 <> [hardBr, mk RowMark "| ", mk CellMark "|", "c", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 9, rng 10 11]]]) + "|a|\n|-|\n||" <===> ([mk RowMark "|", "a", mk CellMark "|", hardBr, mk (DelimiterRow [TANone]) "|-|", hardBr, mk RowMark "|", mk CellMark "|"], [Table [TANone] [rng 1 2] [[rng 7 7]]]) + " | a |\n |---|\n | b |" <===> ([mk RowMark " | ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) " |---|", hardBr, mk RowMark " | ", "b", mk CellMark " |"], [Table [TANone] [rng 1 2] [[rng 7 8]]]) + it "inline content in cells" do + "| *a* | @b |\n|---|---|" <===> ([mk RowMark "| ", fmt boldF "a", mk CellMark " | ", fmt (Mention "b") "@b", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TANone]) "|---|---|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] []]) + it "lines that are not tables" do + noBlocks "| a |\n| b |" + noBlocks "| a | b |\n|---|" + noBlocks "| a |\n|---|x" + noBlocks "a\n-|-" + it "tables and other blocks" do + "a | b\n- | -\nc" <===> ([mk RowMark "", "a", mk CellMark " | ", "b", mk CellMark "", hardBr, mk (DelimiterRow [TANone, TANone]) "- | -", lineBr, "c"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [], Paragraph (rng 8 9)]) + "| a |\n|---|\n- b | c" <===> ([mk RowMark "| ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) "|---|", lineBr, bullet "- ", "b | c"], [Table [TANone] [rng 1 2] [], List [entry 7 8 []]]) + "x\n| a |\n|---|" <===> (["x", lineBr, mk RowMark "| ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) "|---|"], [Paragraph (rng 0 1), Table [TANone] [rng 3 4] []]) + where + header2 = [mk RowMark "|", "a", mk CellMark "|", "b", mk CellMark "|", hardBr, mk (DelimiterRow [TANone, TANone]) "|-|-|"] + +formatsAcrossLines :: Spec +formatsAcrossLines = describe "formats across line breaks" do + it "each delimited format, in a paragraph" do + "*a\nb*" ==>> [fmt boldF "a", "\n", fmt boldF "b"] + "**a\nb**" ==>> [fmt bold2F "a", "\n", fmt bold2F "b"] + "__a\nb__" ==>> [fmt bold2F "a", "\n", fmt bold2F "b"] + "_a\nb_" ==>> [fmt Italic "a", "\n", fmt Italic "b"] + "~a\nb~" ==>> [fmt StrikeThrough "a", "\n", fmt StrikeThrough "b"] + "~~a\nb~~" ==>> [fmt StrikeThrough "a", "\n", fmt StrikeThrough "b"] + "`a\nb`" ==>> [fmt Snippet "a", "\n", fmt Snippet "b"] + "#a\nb#" ==>> [fmt Secret "a", "\n", fmt Secret "b"] + "!1 a\nb!" ==>> [fmt (colored Red) "a", "\n", fmt (colored Red) "b"] + "!- a\nb!" ==>> [fmt Small "a", "\n", fmt Small "b"] + "*a\nb\nc*" ==>> [fmt boldF "a", "\n", fmt boldF "b", "\n", fmt boldF "c"] + "*a \nb*" ==>> [fmt boldF "a", " \n", fmt boldF "b"] + "# h\n*a\nb*" ===> ([heading1, "h", lineBr, fmt boldF "a", softBr, fmt boldF "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 6)]) + it "in a list item and a quote" do + "- *a\n b*" ===> ([bullet "- ", fmt boldF "a", softBr, mk Indent " ", fmt boldF "b"], [List [entry 1 5 []]]) + "- *a\nb*" ===> ([bullet "- ", fmt boldF "a", softBr, fmt boldF "b"], [List [entry 1 4 []]]) + "> *a\n> b*" ===> (quoted, [Quote (rng 1 5)]) + "> *a*\n> *b*" <===> (quoted, [Quote (rng 1 5)]) + "> *a\nb*" ===> ([quoteMark "> ", fmt boldF "a", softBr, fmt boldF "b"], [Quote (rng 1 4)]) + it "not across blocks, blank lines and cells" do + parseMaybeMarkdownList "*a\n\nb*" `shouldBe` Nothing + "# *a\nb*" <===> ([heading1, "*a", lineBr, "b*"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "- *a\n- b*" <===> ([bullet "- ", "*a", lineBr, bullet "- ", "b*"], [List [entry 1 2 [], entry 4 5 []]]) + "> *a\n>\n> b*" <===> ([quoteMark "> ", "*a", hardBr, quoteMark ">", hardBr, quoteMark "> ", "b*"], [Quote (rng 1 7)]) + "| *a | b* |\n|---|---|" <===> ([mk RowMark "| ", "*a", mk CellMark " | ", "b*", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TANone]) "|---|---|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] []]) + it "a format starting or ending at a line break keeps its empty part" do + "# h\n`\na`" ===> ([heading1, "h", lineBr, fmt Snippet "", softBr, fmt Snippet "a"], [Heading 1 (rng 1 2), Paragraph (rng 3 6)]) + "# h\n``\n``" <===> ([heading1, "h", lineBr, fmt Snippet "", softBr, fmt Snippet ""], [Heading 1 (rng 1 2), Paragraph (rng 3 6)]) + T.concat (map markdownText $ fromMaybe [] $ parseMaybeMarkdownList "# h\n`\na`") `shouldBe` "# h\n``\n`a`" + it "an unclosed delimiter is text to the line end" do + "*a\n_b_" ==>> ["*a\n", fmt Italic "b"] + parseMaybeMarkdownList "*a\nb" `shouldBe` Nothing + it "white space at the content ends: as on master, within the line" do + parseMaybeMarkdownList "*a\n*" `shouldBe` Nothing + parseMaybeMarkdownList "*a\nb *" `shouldBe` Nothing + parseMaybeMarkdownList "_ a\nb_" `shouldBe` Nothing + parseMaybeMarkdownList "**a\n**" `shouldBe` Nothing + "5 * 3\n*b*" ==>> ["5 * 3\n", fmt boldF "b"] + it "links, mentions and names within one line" do + parseMaybeMarkdownList "[a\nb](https://simplex.chat)" `shouldBe` Nothing + parseMaybeMarkdownList "@'a\nb'" `shouldBe` Nothing + "https://simplex.chat\nb" ==>> [uri' "https://simplex.chat", "\nb"] + "@alice\nb" ==>> [fmt (Mention "alice") "@alice", "\nb"] + where + quoted = [quoteMark "> ", fmt boldF "a", softBr, quoteMark "> ", fmt boldF "b"] + +liveMessages :: Spec +liveMessages = describe "incomplete syntax of live messages" do + it "a code block once a line follows the fence" do + noBlocks "```" + noBlocks "```js" + "```\nls" <===> ([fence "```", hardBr, "ls"], [CodeBlock Nothing (rng 2 3)]) + "``` a ```" ==>> [fmt Snippet "a"] + it "a rule until another character is added" do + "---" <===> ([mk RuleMark "---"], [Rule (rng 0 1)]) + noBlocks "---x" + it "a table once the delimiter row has a cell" do + noBlocks "| a |" + noBlocks "| a |\n|" + "| a |\n|-" <===> ([mk RowMark "| ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) "|-"], [Table [TANone] [rng 1 2] []]) + noBlocks "| a |\n|---|x" + it "block marks before their text" do + noBlocks "-" + noBlocks "1." + noBlocks ">" + "# a" <===> ([heading1, "a"], [Heading 1 (rng 1 2)]) + "- a" <===> ([bullet "- ", "a"], [List [entry 1 2 []]]) + "1. a" <===> ([numbered "1.", "a"], [List [ListEntry (Just "1.") (rng 1 2) []]]) + "> a" <===> ([quoteMark "> ", "a"], [Quote (rng 1 2)]) + +linearTimeParsing :: Spec +linearTimeParsing = describe "bounded scans for linear-time parsing" do + it "phone numbers: at most 22 characters, as on master" do + "1 1 1 1 1 1 1 1 1 1 1 1" <==> "1 " <> phone "1 1 1 1 1 1 1 1 1 1 1" + it "link texts stop at \"[\", link targets and autolinks at white space" do + "[a [b](https://simplex.chat)" <==> "[a " <> web "b" "https://simplex.chat" "[b](https://simplex.chat)" + "[a](https://simplex.chat x)" <==> "[a](https://simplex.chat x)" + "" <==> "" + it "a text with more than 2000 markup tokens has no blocks" do + let ParsedMarkdown _ blocks = parsedMarkdown $ T.replicate 1000 "- a\n" + length <$> blocks `shouldBe` Just 1 + noBlocks $ T.replicate 1001 "- a\n" + length . filter (\(FormattedText f _) -> f == Just boldF) <$> parseMaybeMarkdownList (T.replicate 1001 "- *a*\n") `shouldBe` Just 1001 + it "large adversarial texts" do + forM_ (["1 ", "[ ", "[a]( ", "< ", "a\n", ">\n", "- a\n", "|\n", "|-|\n", "*a\n"] :: [Text]) $ \p -> + length (show $ parseMaybeMarkdownList $ "|a|\n|-|\n" <> T.replicate 4000 p) `shouldSatisfy` (> 0) + +markdownConsumers :: Spec +markdownConsumers = describe "consumers of parsed Markdown" do + it "mentionedNames: mentions in blocks, not in code" do + mentions "- @alice" `shouldBe` ["alice"] + mentions "> @alice" `shouldBe` ["alice"] + mentions "# @alice" `shouldBe` ["alice"] + mentions "| @alice |\n|---|" `shouldBe` ["alice"] + mentions "```\n@alice\n```" `shouldBe` [] + mentions "`@alice`" `shouldBe` [] + it "hasLinks: links in blocks, not in code" do + links "- https://simplex.chat" `shouldBe` True + links "| https://simplex.chat |\n|---|" `shouldBe` True + links "```\nhttps://simplex.chat\n```" `shouldBe` False + it "SimpleX links in blocks and in code" do + simplexLinks ("- " <> addr) `shouldBe` True + simplexLinks ("| " <> addr <> " |\n|---|") `shouldBe` True + simplexLinks ("```\n" <> addr <> "\n```") `shouldBe` False + hasObfuscatedSimplexLink ("```\n" <> addr <> "\n```") `shouldBe` True + textWithoutSimplexLinks ("- check [this link](" <> addr <> ") out") `shouldBe` "- check out" + it "markdownText of markup tokens is their text" do + forM_ ([LineBreak, SoftBreak, HardBreak, ParagraphBreak 2, Indent, HeadingMark 1, HeadingClose, HeadingUnderline 1, ListMark Nothing, QuoteMark, FenceMark Nothing, RowMark, CellMark, DelimiterRow [TANone], RuleMark] :: [BlockToken]) $ \tkn -> + markdownText (mk tkn "x") `shouldBe` "x" + markdownText (fmt Escape "\\") `shouldBe` "\\" + where + parsed = fromMaybe [] . parseMaybeMarkdownList + mentions = mentionedNames . parsed + links = hasLinks . parsed + isSimplexLinkFormat (FormattedText f _) = maybe False isSimplexLink f + simplexLinks = any isSimplexLinkFormat . parsed + textWithoutSimplexLinks = T.concat . map (\(FormattedText _ t) -> t) . filter (not . isSimplexLinkFormat) . parsed + addr = "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw" testSanitizeUri :: Spec testSanitizeUri = describe "sanitizeUri" $ do diff --git a/website/src/directory.html b/website/src/directory.html index 3505cccc4b..41dea04862 100644 --- a/website/src/directory.html +++ b/website/src/directory.html @@ -123,6 +123,57 @@ active_directory: true color: magenta; } + #directory .entry { + --md-card: rgba(139, 135, 134, 0.12); + --md-line: rgba(139, 135, 134, 0.35); + --md-bar: rgba(0, 0, 0, 0.16); + --md-stripe: rgba(139, 135, 134, 0.08); + --md-link: #0053D0; + } + + .dark #directory .entry { + --md-card: rgba(255, 255, 255, 0.08); + --md-line: rgba(139, 135, 134, 0.45); + --md-bar: rgba(255, 255, 255, 0.22); + --md-stripe: rgba(139, 135, 134, 0.12); + --md-link: #70F0F9; + } + + #directory .entry .text-container { min-width: 0; } + #directory .entry .md-gap { height: 0.7em; } + #directory .entry .md-h1 { font-size: 1.5em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h2 { font-size: 1.3em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h3 { font-size: 1.15em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h4 { font-weight: 700; } + #directory .entry .md-entry { display: flex; gap: 6px; } + #directory .entry .md-mark { flex: none; min-width: 1.1em; } + #directory .entry .md-text { flex: 1; min-width: 0; } + #directory .entry .md-quote { display: flex; gap: 10px; } + #directory .entry .md-bar { flex: none; width: 4px; border-radius: 2px; background: var(--md-bar); } + #directory .entry .md-quote > .md-text { opacity: 0.7; } + #directory .entry .md-code { position: relative; background: var(--md-card); border-radius: 4px; overflow: hidden; } + #directory .entry .md-code pre { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875em; line-height: 1.45; } + #directory .entry .md-copy { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 4px; background: var(--md-card); color: var(--md-link); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s; } + #directory .entry .md-code:hover .md-copy, + #directory .entry .md-code.tapped .md-copy, + #directory .entry .md-copy:focus-visible { opacity: 1; pointer-events: auto; } + #directory .entry .md-copy svg { width: 18px; height: 18px; } + #directory .entry .md-icon-done, + #directory .entry .md-copy.copied .md-icon-copy { display: none; } + #directory .entry .md-copy.copied .md-icon-done { display: block; } + #directory .entry .md-table { overflow-x: auto; } + #directory .entry .md-frame { display: inline-block; vertical-align: top; border: 1px solid var(--md-line); border-radius: 4px; overflow: hidden; } + #directory .entry .md-frame table { border-collapse: collapse; font-variant-numeric: tabular-nums; } + #directory .entry .md-frame th, + #directory .entry .md-frame td { padding: 4px 12px; white-space: nowrap; border-right: 1px solid var(--md-line); border-bottom: 1px solid var(--md-line); } + #directory .entry .md-frame th:last-child, + #directory .entry .md-frame td:last-child { border-right: 0; } + #directory .entry .md-frame tr:last-child th, + #directory .entry .md-frame tr:last-child td { border-bottom: 0; } + #directory .entry .md-frame th { font-weight: 700; background: var(--md-card); } + #directory .entry .md-frame tr:nth-child(odd) td { background: var(--md-stripe); } + #directory .entry .md-rule { border: 0; border-top: 1px solid var(--md-line); margin: 6px 0; } + .pagination { display: flex; justify-content: center; diff --git a/website/src/js/channel-preview.jsc b/website/src/js/channel-preview.jsc index b0a1696010..d754d36b9c 100644 --- a/website/src/js/channel-preview.jsc +++ b/website/src/js/channel-preview.jsc @@ -23,6 +23,9 @@ const STYLE = ` --sp-color-white: #000; --sp-qr-fg: #062D56; --sp-qr-bg: #ffffff; + --sp-md-line: rgba(139, 135, 134, 0.35); + --sp-md-bar: rgba(0, 0, 0, 0.16); + --sp-md-stripe: rgba(139, 135, 134, 0.08); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 1.4; @@ -58,6 +61,9 @@ const STYLE = ` --sp-color-white: #fff; --sp-qr-fg: #FFFBFA; --sp-qr-bg: transparent; + --sp-md-line: rgba(139, 135, 134, 0.45); + --sp-md-bar: rgba(255, 255, 255, 0.22); + --sp-md-stripe: rgba(139, 135, 134, 0.12); } .simplex-preview-header { @@ -497,6 +503,44 @@ const STYLE = ` .simplex-preview-text .black { color: var(--sp-color-black); } .simplex-preview-text .white { color: var(--sp-color-white); } +.simplex-preview-msg-col { + min-width: 0; +} + +.simplex-preview-text .md-gap { height: 0.7em; } +.simplex-preview-text .md-h1 { font-size: 1.5em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h2 { font-size: 1.3em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h3 { font-size: 1.15em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h4 { font-weight: 700; } +.simplex-preview-text .md-entry { display: flex; gap: 6px; } +.simplex-preview-text .md-mark { flex: none; min-width: 1.1em; } +.simplex-preview-text .md-text { flex: 1; min-width: 0; } +.simplex-preview-text .md-quote { display: flex; gap: 10px; } +.simplex-preview-text .md-bar { flex: none; width: 4px; border-radius: 2px; background: var(--sp-md-bar); } +.simplex-preview-text .md-quote > .md-text { opacity: 0.7; } +.simplex-preview-text .md-code { position: relative; background: var(--sp-quote); border-radius: 10px; overflow: hidden; } +.simplex-preview-text .md-code pre { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875em; line-height: 1.45; } +.simplex-preview-text .md-copy { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 7px; background: var(--sp-quote); color: var(--sp-link); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s; } +.simplex-preview-text .md-code:hover .md-copy, +.simplex-preview-text .md-code.tapped .md-copy, +.simplex-preview-text .md-copy:focus-visible { opacity: 1; pointer-events: auto; } +.simplex-preview-text .md-copy svg { width: 18px; height: 18px; } +.simplex-preview-text .md-icon-done, +.simplex-preview-text .md-copy.copied .md-icon-copy { display: none; } +.simplex-preview-text .md-copy.copied .md-icon-done { display: block; } +.simplex-preview-text .md-table { overflow-x: auto; } +.simplex-preview-text .md-frame { display: inline-block; vertical-align: top; border: 1px solid var(--sp-md-line); border-radius: 10px; overflow: hidden; } +.simplex-preview-text .md-frame table { border-collapse: collapse; font-variant-numeric: tabular-nums; } +.simplex-preview-text .md-frame th, +.simplex-preview-text .md-frame td { padding: 4px 12px; white-space: nowrap; border-right: 1px solid var(--sp-md-line); border-bottom: 1px solid var(--sp-md-line); } +.simplex-preview-text .md-frame th:last-child, +.simplex-preview-text .md-frame td:last-child { border-right: 0; } +.simplex-preview-text .md-frame tr:last-child th, +.simplex-preview-text .md-frame tr:last-child td { border-bottom: 0; } +.simplex-preview-text .md-frame th { font-weight: 700; background: var(--sp-quote); } +.simplex-preview-text .md-frame tr:nth-child(odd) td { background: var(--sp-md-stripe); } +.simplex-preview-text .md-rule { border: 0; border-top: 1px solid var(--sp-md-line); margin: 6px 0; } + .simplex-preview-main { flex: 1; min-width: 0; @@ -885,6 +929,7 @@ function render(container, data, channelLink, showAppBadges) { ts: messages.length > 0 ? messages[0].ts : new Date().toISOString(), content: { type: 'text', text: typeof welcome === 'string' ? welcome : '' }, formattedText: Array.isArray(welcome) ? welcome : null, + markdownBlocks: data.welcomeMessageBlocks, reactions: [] }; allMessages = [welcomeMsg].concat(messages); @@ -987,7 +1032,7 @@ function renderInfoContent(container, data, channelLink, subscriberCount, showAp if (shortDescr) { const descrDiv = document.createElement('div'); descrDiv.className = 'simplex-preview-info-descr'; - descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr) : escapeHtml(shortDescr); + descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr, data.shortDescriptionBlocks) : escapeHtml(shortDescr); container.appendChild(descrDiv); } @@ -1068,25 +1113,6 @@ function qrColor(value, fallback) { return value === 'transparent' ? '#0000' : value; } -// navigator.clipboard is undefined outside secure contexts; fall back to execCommand -function copyToClipboard(text) { - if (navigator.clipboard && navigator.clipboard.writeText) { - return navigator.clipboard.writeText(text); - } - return new Promise(function(resolve, reject) { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.top = '-9999px'; - document.body.appendChild(ta); - ta.select(); - let ok = false; - try { ok = document.execCommand('copy'); } catch (e) {} - document.body.removeChild(ta); - ok ? resolve() : reject(new Error('copy failed')); - }); -} - function renderDesktopConversion(container, channelLink, showAppBadges) { if (showAppBadges) { renderAppBadges(container); @@ -1190,6 +1216,7 @@ function setupSecretToggles(container) { container.addEventListener('click', (e) => { const secret = e.target.closest('.secret'); if (secret) secret.classList.toggle('visible'); + handleCodeClick(e); }); } @@ -1246,6 +1273,7 @@ function renderMessages(container, messages, membersMap, channel) { } const col = document.createElement('div'); + col.className = 'simplex-preview-msg-col'; const bubble = renderBubble(msg, member, showTail, membersMap, channel); col.appendChild(bubble); @@ -1519,7 +1547,7 @@ function appendTextBlock(bubble, msg) { textDiv.className = 'simplex-preview-text'; const meta = renderMetaHTML(msg); if (msg.formattedText && msg.formattedText.length > 0) { - textDiv.innerHTML = renderMarkdown(msg.formattedText) + meta; + textDiv.innerHTML = renderMarkdown(msg.formattedText, msg.markdownBlocks) + meta; } else { textDiv.innerHTML = escapeHtml(msg.content.text || '') + meta; } diff --git a/website/src/js/directory.jsc b/website/src/js/directory.jsc index 5ca3b7fbcf..dd07735260 100644 --- a/website/src/js/directory.jsc +++ b/website/src/js/directory.jsc @@ -32,6 +32,7 @@ async function initDirectory() { liveBtn.addEventListener('click', () => renderEntries('live', byActiveAtDesc, liveBtn)); newBtn.addEventListener('click', () => renderEntries('new', byCreatedAtDesc, newBtn)); topBtn.addEventListener('click', () => renderEntries('top', bySortPriority, topBtn)); + document.getElementById('directory').addEventListener('click', handleCodeClick); window.addEventListener('popstate', applyHash); function applyHash() { @@ -229,7 +230,7 @@ function displayEntries(entries) { for (let entry of entries) { try { - const { entryType, displayName, simplexName, groupLink, shortDescr, welcomeMessage, imageFile } = entry; + const { entryType, displayName, simplexName, groupLink, shortDescr, shortDescrBlocks, welcomeMessage, welcomeMessageBlocks, imageFile } = entry; const entryDiv = document.createElement('div'); entryDiv.className = 'entry w-full flex flex-col items-start md:flex-row rounded-[4px] overflow-hidden shadow-[0px_20px_30px_rgba(0,0,0,0.12)] dark:shadow-none bg-white dark:bg-[#0B2A59] mb-8'; @@ -248,11 +249,11 @@ function displayEntries(entries) { textContainer.appendChild(simplexNameElement); } - const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined; - const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined; + const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage, welcomeMessageBlocks) : undefined; + const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr, shortDescrBlocks) : undefined; if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) { const descrElement = document.createElement('p'); - descrElement.innerHTML = renderMarkdown(shortDescr); + descrElement.innerHTML = shortDescrHTML; textContainer.appendChild(descrElement); } diff --git a/website/src/js/simplex-lib.jsc b/website/src/js/simplex-lib.jsc index d05dd6a931..73e304f9dd 100644 --- a/website/src/js/simplex-lib.jsc +++ b/website/src/js/simplex-lib.jsc @@ -54,7 +54,157 @@ function targetBlank(uri) { return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"' } -function renderMarkdown(fts) { +const MAX_LIST_DEPTH = 7; + +const MAX_TABLE_CELLS = 1000; + +const COPY_BUTTON = + ''; + +// navigator.clipboard is undefined outside secure contexts; fall back to execCommand +function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return new Promise(function(resolve, reject) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (e) {} + document.body.removeChild(ta); + ok ? resolve() : reject(new Error('copy failed')); + }); +} + +function handleCodeClick(e) { + const card = e.target.closest('.md-code'); + if (!card) return; + const button = e.target.closest('.md-copy'); + if (!button) { + card.classList.toggle('tapped'); + return; + } + const pre = card.querySelector('pre'); + if (!pre) return; + copyToClipboard(pre.innerText).then(() => { + button.classList.add('copied'); + setTimeout(() => button.classList.remove('copied'), 1500); + }).catch(() => {}); +} + +function validBlocks(fts, blocks) { + let pos = 0; + const range = (r) => { + const valid = !!r && Number.isInteger(r.from) && Number.isInteger(r.to) && pos <= r.from && r.from <= r.to && r.to <= fts.length; + if (valid) pos = r.to; + return valid; + }; + const entries = (items, depth) => + Array.isArray(items) && (items.length === 0 || depth <= MAX_LIST_DEPTH) && items.every((e) => !!e && range(e.content) && entries(e.items, depth + 1)); + const rows = (rs) => Array.isArray(rs) && rs.every((row) => Array.isArray(row) && row.every(range)); + return Array.isArray(blocks) && blocks.every((b) => { + switch (b && b.type) { + case 'paragraph': + case 'heading': + case 'codeBlock': + case 'quote': + return range(b.content); + case 'list': + return entries(b.items, 1); + case 'table': + return Array.isArray(b.columns) && rows([b.header]) && rows(b.rows); + case 'rule': + return range(b.mark); + default: + return false; + } + }); +} + +function renderBlocks(fts, blocks) { + let html = ''; + let pos = 0; + const segments = (r) => renderSegments(fts.slice(r.from, r.to), true); + const isParagraphBreak = (ft) => ft.format && ft.format.type === 'markup' && ft.format.token && ft.format.token.type === 'paragraphBreak'; + const gap = (to) => { + if (html && fts.slice(pos, to).some(isParagraphBreak)) html += '

'; + pos = Math.max(pos, to); + }; + const block = (r, open, close) => { + gap(r.from); + html += open + segments(r) + close; + pos = r.to; + }; + const entry = (e, level) => { + html += `
${escapeHtml(String(e.number ?? '•'))}${segments(e.content)}
`; + pos = e.content.to; + e.items.forEach((item) => entry(item, level + 1)); + }; + const table = (b) => { + const rows = [b.header].concat(b.rows); + const width = b.columns.length * rows.length <= MAX_TABLE_CELLS ? b.columns.length : 0; + const align = (c) => (b.columns[c] === 'center' || b.columns[c] === 'right' ? b.columns[c] : 'left'); + const cell = (tag, c, r) => `<${tag} style="text-align: ${align(c)};">${r ? segments(r) : ''}`; + const row = (cells, tag) => + '
' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '
'; + const last = rows[rows.length - 1]; + pos = last.length > 0 ? last[last.length - 1].to : pos; + }; + for (const b of blocks) { + switch (b.type) { + case 'paragraph': + block(b.content, '
', '
'); + break; + case 'heading': + block(b.content, `
`, '
'); + break; + case 'codeBlock': + block(b.content, '
', '
' + COPY_BUTTON + '
'); + break; + case 'quote': + block(b.content, '
', '
'); + break; + case 'list': + if (b.items.length > 0) gap(b.items[0].content.from); + b.items.forEach((item) => entry(item, 0)); + break; + case 'table': + table(b); + break; + case 'rule': + block(b.mark, '
', ''); + break; + } + } + return html; +} + +function markupHtml(token) { + switch (token && token.type) { + case 'softBreak': + return ' '; + case 'lineBreak': + case 'hardBreak': + return '
'; + default: + return ''; + } +} + +function renderMarkdown(fts, blocks) { + return validBlocks(fts, blocks) ? renderBlocks(fts, blocks) : renderSegments(fts, false); +} + +function renderSegments(fts, withBlocks) { let html = ''; for (const ft of fts) { const { format, text } = ft; @@ -65,7 +215,7 @@ function renderMarkdown(fts) { try { switch (format.type) { case 'bold': - html += `${escapeHtml(text)}`; + html += withBlocks && format.singleStar ? `${escapeHtml(text)}` : `${escapeHtml(text)}`; break; case 'italic': html += `${escapeHtml(text)}`; @@ -113,6 +263,11 @@ function renderMarkdown(fts) { case 'phone': html += `${escapeHtml(text)}`; break; + case 'escape': + break; + case 'markup': + html += withBlocks ? markupHtml(format.token) : escapeHtml(text); + break; case 'unknown': html += escapeHtml(text); break; From a766d0af0f4048ecec96ab367461f80e38f7586f Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 19 Oct 2026 07:50:09 +0100 Subject: [PATCH 07/10] ci: package releases with drafts in simplex-chat-libs (#7754) * ci: package releases with drafts in simplex-chat-libs * test release * core: 7.1.0.12 * packages: release nodejs and python libraries * fixes --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .github/workflows/build.yml | 438 +++++++++++--- .github/workflows/packages.yml | 306 +--------- docs/contributing/RELEASES.md | 153 +++++ .../types/typescript/README.md | 2 + .../types/typescript/package.json | 3 +- packages/simplex-chat-nodejs/package.json | 4 +- packages/simplex-chat-nodejs/src/release.ts | 14 +- packages/simplex-chat-python/README.md | 9 - .../src/simplex_chat/_version.py | 18 +- plans/2026-10-18-package-releases.md | 364 ++++++++++++ scripts/ci/package-tests.sh | 81 +++ scripts/ci/packages_release.py | 548 ++++++++++++++---- scripts/ci/test_packages_release.py | 414 +++++++++++-- simplex-chat.cabal | 2 +- 14 files changed, 1797 insertions(+), 559 deletions(-) create mode 100644 docs/contributing/RELEASES.md create mode 100644 plans/2026-10-18-package-releases.md create mode 100755 scripts/ci/package-tests.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index be1784a237..074a6feb48 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,7 @@ env: release: ${{ startsWith(github.ref, 'refs/tags/v') }} branch: ${{ github.base_ref || github.ref_name }} cache_o0: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') }} + release_branch: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') && github.ref_name || '' }} ZSTD_CLEVEL: 9 # This workflow uses custom actions (prepare-build and prepare-release) defined in: @@ -74,7 +75,8 @@ jobs: # ============================= # Create release, but only if it's triggered by tag push. -# Create simplex-chat-libs release core-, but only if the head commit of a push to master or stable changes the version in simplex-chat.cabal. +# On pushes to master or stable: claim the simplex-chat-libs release core- for the branch +# and create its draft, create the draft of a missing add-on release, and find the package versions to publish. # On pull requests and other pushes, this job will always complete. # Secrets: @@ -87,8 +89,15 @@ jobs: maybe-release: runs-on: ubuntu-latest outputs: - libs_tag: ${{ steps.libs_commit.outputs.tag }} + libs_tag: ${{ steps.libs.outputs.tag }} core_changed: ${{ steps.core.outputs.core }} + addon_tag: ${{ steps.addon.outputs.tag }} + addon_draft: ${{ steps.addon.outputs.draft }} + libs_latest: ${{ steps.latest_libs.outputs.tag }} + publish_types: ${{ steps.libs.outputs.tag && steps.publish.outputs.types_version || '' }} + publish_nodejs: ${{ steps.publish.outputs.nodejs_version }} + publish_python: ${{ steps.publish.outputs.python_version }} + publish: ${{ steps.publish.outputs.nodejs_version || steps.publish.outputs.python_version }} steps: - name: Clone project if: github.event_name == 'push' || github.event_name == 'pull_request' @@ -129,38 +138,68 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Check version change - id: check_version - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable' + - name: Library release + id: libs + if: env.release_branch shell: bash - run: | - version=$(sed -n 's/^version: *//p' simplex-chat.cabal) - prev_version=$(git show HEAD^1:simplex-chat.cabal | sed -n 's/^version: *//p') - if [ "$version" != "$prev_version" ]; then - echo "version=$version" >> $GITHUB_OUTPUT - fi - - - name: Record libs release - id: libs_commit - if: steps.check_version.outputs.version env: GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} LIBS_REPOSITORY: ${{ github.repository }}-libs - VERSION: ${{ steps.check_version.outputs.version }} run: | - commit=$(python3 scripts/ci/packages_release.py record --core "$VERSION" --commit "$GITHUB_SHA") - echo "tag=core-$VERSION" >> "$GITHUB_OUTPUT" - echo "commit=$commit" >> "$GITHUB_OUTPUT" + version=$(sed -n 's/^version: *//p' simplex-chat.cabal) + tag=core-$version + claim_output=$(python3 scripts/ci/packages_release.py claim-core "$version" "$GITHUB_SHA" "$release_branch") + eval "$claim_output" + if [ "$claim" = skip ]; then + echo "::notice title=Library release::core $version is recorded for another commit" + exit 0 + fi + status_output=$(python3 scripts/ci/packages_release.py release-status "$tag") + eval "$status_output" + if [ "$status" = absent ]; then + gh release create "$tag" --repo "$LIBS_REPOSITORY" --draft --title "$tag" --notes "" ${commit:+--target "$commit"} + fi + if [ "$status" != published ]; then + echo "tag=$tag" >> "$GITHUB_OUTPUT" + fi - # 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 }} + - name: Add-on release + id: addon + if: env.release_branch + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: | + addon_output=$(python3 scripts/ci/packages_release.py addon-release) + eval "$addon_output" + if [ "$status" = absent ]; then + tag=nodejs-addon-${GITHUB_SHA::7} + gh release create "$tag" --repo "$LIBS_REPOSITORY" --draft --title "$tag" --notes "$notes" + fi + if [ "$status" != published ]; then + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "draft=true" >> "$GITHUB_OUTPUT" + fi + + - name: Latest library + id: latest_libs + if: steps.addon.outputs.draft + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: python3 scripts/ci/packages_release.py latest-libs --branch "$release_branch" >> "$GITHUB_OUTPUT" + + - name: Versions to publish + id: publish + if: env.release_branch + env: + GH_TOKEN: ${{ github.token }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + NPM_SCOPE: ${{ vars.NPM_SCOPE }} + PYPI_PROJECT: ${{ vars.PYPI_PROJECT }} + PYPI_URL: ${{ vars.PYPI_URL }} + run: python3 scripts/ci/packages_release.py publish-plan >> "$GITHUB_OUTPUT" # ========================= # Linux Build @@ -444,12 +483,11 @@ jobs: - name: Upload library if: needs.maybe-release.outputs.libs_tag && matrix.libs - uses: softprops/action-gh-release@v2 - with: - repository: ${{ github.repository }}-libs - tag_name: ${{ needs.maybe-release.outputs.libs_tag }} - files: release-libs/*.zip - token: ${{ secrets.NODEJS_REPO_TOKEN }} + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" release-libs/*.zip --clobber --repo "${{ github.repository }}-libs" - name: Build CLI deb if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true @@ -591,7 +629,7 @@ jobs: - name: Setup Node.js id: setup-node - if: ${{ !cancelled() && needs.maybe-release.outputs.core_changed && steps.build-lib.outcome == 'success' }} + if: ${{ !cancelled() && ((needs.maybe-release.outputs.core_changed && steps.build-lib.outcome == 'success') || (matrix.libs && (needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish))) }} uses: actions/setup-node@v7 with: node-version: 22 @@ -603,35 +641,42 @@ jobs: with: python-version: "3.12" - - name: Build simplex-chat (npm) and add-on - id: build-nodejs - if: ${{ !cancelled() && steps.setup-python.outcome == 'success' }} - shell: bash - 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 --yes node-gyp rebuild - npx tsc && cp src/simplex.* dist - - - name: Test simplex-chat (npm) - id: test-nodejs - if: ${{ !cancelled() && steps.build-nodejs.outcome == 'success' }} + - name: Package tests (pull request) + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.core_changed }} timeout-minutes: 30 shell: bash - working-directory: packages/simplex-chat-nodejs env: SIMPLEX_LIBS_DIR: ${{ github.workspace }}/release-libs/simplex-chat-libs-linux-${{ matrix.arch }}/libs - run: npx jest --forceExit --testNamePattern '^(?!.*use preset servers)' + run: | + scripts/ci/package-tests.sh build linux-${{ matrix.arch }} + scripts/ci/package-tests.sh test - - name: Test simplex-chat (PyPI) - if: ${{ !cancelled() && steps.test-nodejs.outcome == 'success' }} + - name: Add-on + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + timeout-minutes: 30 + shell: bash + env: + LIBS_RELEASE: ${{ needs.maybe-release.outputs.libs_latest }} + ADDON_TAG: ${{ needs.maybe-release.outputs.addon_tag }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + run: scripts/ci/package-tests.sh addon linux-${{ matrix.arch }} + + - name: Package tests + id: package-tests + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} timeout-minutes: 30 shell: bash - working-directory: packages/simplex-chat-python run: | - python -m pip install -e ".[test]" - python -m pytest -q + scripts/ci/package-tests.sh publish linux-${{ matrix.arch }} ${{ needs.maybe-release.outputs.publish_nodejs && 'nodejs' || '' }} ${{ needs.maybe-release.outputs.publish_python && 'python' || '' }} + echo "linux-${{ matrix.arch }}" > "$RUNNER_TEMP/package-tests" + + - name: Package tests result + if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: package-tests-linux-${{ matrix.arch }} + path: ${{ runner.temp }}/package-tests - name: Remove -O2 build directory id: remove-o2 @@ -821,12 +866,11 @@ jobs: (cd postgres-libs && zip -r simplex-chat-libs-linux-x86_64-postgres.zip libs) - name: Upload postgres library - uses: softprops/action-gh-release@v2 - with: - repository: ${{ github.repository }}-libs - tag_name: ${{ needs.maybe-release.outputs.libs_tag }} - files: postgres-libs/*.zip - token: ${{ secrets.NODEJS_REPO_TOKEN }} + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" postgres-libs/*.zip --clobber --repo "${{ github.repository }}-libs" # ========================= # MacOS Build @@ -1084,12 +1128,52 @@ jobs: - name: Upload library if: ${{ !cancelled() && steps.mac_lib_build.outcome == 'success' }} - uses: softprops/action-gh-release@v2 + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" release-libs/*.zip --clobber --repo "${{ github.repository }}-libs" + + - name: Setup Node.js + id: setup-node + if: ${{ !cancelled() && (needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish) }} + uses: actions/setup-node@v7 with: - repository: ${{ github.repository }}-libs - tag_name: ${{ needs.maybe-release.outputs.libs_tag }} - files: release-libs/*.zip - token: ${{ secrets.NODEJS_REPO_TOKEN }} + node-version: 22 + + - name: Setup Python + id: setup-python + if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Add-on + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + timeout-minutes: 30 + shell: bash + env: + LIBS_RELEASE: ${{ needs.maybe-release.outputs.libs_latest }} + ADDON_TAG: ${{ needs.maybe-release.outputs.addon_tag }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + run: scripts/ci/package-tests.sh addon macos-${{ matrix.arch }} + + - name: Package tests + id: package-tests + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} + timeout-minutes: 30 + shell: bash + run: | + scripts/ci/package-tests.sh publish macos-${{ matrix.arch }} ${{ needs.maybe-release.outputs.publish_nodejs && 'nodejs' || '' }} ${{ needs.maybe-release.outputs.publish_python && 'python' || '' }} + echo "macos-${{ matrix.arch }}" > "$RUNNER_TEMP/package-tests" + + - name: Package tests result + if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: package-tests-macos-${{ matrix.arch }} + path: ${{ runner.temp }}/package-tests - name: Remove -O2 build directory id: remove-o2 @@ -1305,12 +1389,52 @@ jobs: - name: Upload library if: needs.maybe-release.outputs.libs_tag - uses: softprops/action-gh-release@v2 + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" release-libs/*.zip --clobber --repo "${{ github.repository }}-libs" + + - name: Setup Node.js + id: setup-node + if: ${{ !cancelled() && (needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish) }} + uses: actions/setup-node@v7 with: - repository: ${{ github.repository }}-libs - tag_name: ${{ needs.maybe-release.outputs.libs_tag }} - files: release-libs/*.zip - token: ${{ secrets.NODEJS_REPO_TOKEN }} + node-version: 22 + + - name: Setup Python + id: setup-python + if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Add-on + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + timeout-minutes: 30 + shell: bash + env: + LIBS_RELEASE: ${{ needs.maybe-release.outputs.libs_latest }} + ADDON_TAG: ${{ needs.maybe-release.outputs.addon_tag }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + run: scripts/ci/package-tests.sh addon windows-x86_64 + + - name: Package tests + id: package-tests + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} + timeout-minutes: 30 + shell: bash + run: | + scripts/ci/package-tests.sh publish windows-x86_64 ${{ needs.maybe-release.outputs.publish_nodejs && 'nodejs' || '' }} ${{ needs.maybe-release.outputs.publish_python && 'python' || '' }} + echo "windows-x86_64" > "$RUNNER_TEMP/package-tests" + + - name: Package tests result + if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: package-tests-windows-x86_64 + path: ${{ runner.temp }}/package-tests - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') @@ -1387,19 +1511,169 @@ jobs: key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} # ========================= -# Packages release +# Releases and packages # ========================= -# A new library release is tested with the Node.js and Python packages and updates them in a release PR. - - packages: + releases: needs: [maybe-release, build-linux, build-linux-push, 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 + if: ${{ !cancelled() && (needs.maybe-release.outputs.libs_tag || needs.maybe-release.outputs.addon_draft) }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Publish library release + if: needs.maybe-release.outputs.libs_tag + run: python3 scripts/ci/packages_release.py publish-release "${{ needs.maybe-release.outputs.libs_tag }}" + + - name: Publish add-on release + if: ${{ !cancelled() && needs.maybe-release.outputs.addon_draft }} + run: python3 scripts/ci/packages_release.py publish-release "${{ needs.maybe-release.outputs.addon_tag }}" + +# 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. + + publish: + needs: [maybe-release, build-linux, build-linux-push, build-macos, build-windows, releases] + if: ${{ !cancelled() && (needs.maybe-release.outputs.publish || needs.maybe-release.outputs.publish_types) }} + runs-on: ubuntu-latest permissions: contents: read id-token: write - uses: ./.github/workflows/packages.yml - with: - libs_release: ${{ needs.maybe-release.outputs.libs_tag }} - secrets: inherit + # 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 }} + NPM_SCOPE: ${{ vars.NPM_SCOPE }} + PYPI_PROJECT: ${{ vars.PYPI_PROJECT }} + PYPI_URL: ${{ vars.PYPI_URL }} + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Download package tests results + if: needs.maybe-release.outputs.publish + uses: actions/download-artifact@v8 + with: + pattern: package-tests-* + path: ${{ runner.temp }}/package-tests + + - name: Check package tests and pins + if: needs.maybe-release.outputs.publish + env: + GH_TOKEN: ${{ github.token }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: | + passed=$(find "$RUNNER_TEMP/package-tests" -mindepth 1 -maxdepth 1 -type d -name 'package-tests-*' | wc -l) + if [ "$passed" -ne 5 ]; then + echo "::error::package tests passed on $passed of 5 platforms" + exit 1 + fi + python3 scripts/ci/packages_release.py check-pins + + - 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 && needs.maybe-release.outputs.publish_types && needs.releases.result == 'success' + 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" + + 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" diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 2e58fb4d34..e5ed17c4f8 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -1,60 +1,6 @@ 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 new library release - workflow_call: - inputs: - 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/**" @@ -79,47 +25,19 @@ 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_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-release "$LIBS_RELEASE" >> "$GITHUB_OUTPUT" + git fetch -q --depth=1 origin "$BASE" + python3 scripts/ci/packages_release.py build-plan --base "$BASE" >> "$GITHUB_OUTPUT" build: name: "build ${{ matrix.platform }}" @@ -134,7 +52,6 @@ jobs: defaults: run: shell: bash - working-directory: packages/simplex-chat-nodejs steps: - name: Checkout Code uses: actions/checkout@v6 @@ -149,222 +66,9 @@ jobs: 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 + run: scripts/ci/package-tests.sh build ${{ matrix.platform }} - - 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_release - run: python -m zipfile -e "$RUNNER_TEMP/libsimplex/simplex-chat-libs-${{ matrix.platform }}.zip" "$RUNNER_TEMP/libsimplex" - - - name: Test simplex-chat (npm) + - name: Test simplex-chat (npm and PyPI) if: needs.addon.outputs.test - env: - SIMPLEX_LIBS_DIR: ${{ 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 + run: scripts/ci/package-tests.sh test diff --git a/docs/contributing/RELEASES.md b/docs/contributing/RELEASES.md new file mode 100644 index 0000000000..ea6123e4c8 --- /dev/null +++ b/docs/contributing/RELEASES.md @@ -0,0 +1,153 @@ +# Library and package releases + +## Releases + +| Release | Location | Version | Published on | +|---|---|---|---| +| libsimplex | simplex-chat-libs release `core-` | `version` of `simplex-chat.cabal` | push to master or stable with a new core version | +| Node.js add-on | simplex-chat-libs release `nodejs-addon-<7 characters of the commit>` | | push to master or stable with unreleased add-on sources | +| `@simplex-chat/types` | npm | `version` of `packages/simplex-chat-client/types/typescript/package.json` | library release with an unpublished types version | +| `simplex-chat` | npm | `version` of `packages/simplex-chat-nodejs/package.json` | push to master or stable with an unpublished version | +| `simplex-chat` | PyPI | `__version__` of `packages/simplex-chat-python/src/simplex_chat/_version.py` | push to master or stable with an unpublished version | + +Add-on sources: +- `packages/simplex-chat-nodejs/binding.gyp` +- `packages/simplex-chat-nodejs/cpp/` +- `node-addon-api` version in `devDependencies` of `packages/simplex-chat-nodejs/package.json` + +Release states in simplex-chat-libs: + +``` +draft created by maybe-release; assets uploaded by the build jobs +published every asset present; published by the releases job +``` + +npm tag: `beta` for a version with a prerelease part, `latest` otherwise. + +Releases and publications are independent of the Haskell test results. + +`releases.toml` of simplex-chat-libs, written by `build.yml`: + +``` +core.. commit of the library release; branch: master or stable +npm.. commit of the publication +pypi.. commit of the publication +``` + +## Library release + +1. On master or stable, after `git pull`: + + ``` + python3 scripts/ci/packages_release.py core-release + ``` + + Checked: version higher than the highest version recorded for the branch. + + Written: + - `version` of `simplex-chat.cabal` + - with types changed since the previous library release of the branch: types `version` (bumped), `simplexChatCore`, line `SimpleX Chat core:` of the types README + + `--types-version` sets the types version, for the first release of an app version, e.g. `--types-version 7.1.0-beta.7.1`. + +2. Commit, push to the same branch. + +3. Run of `build.yml`: + - `maybe-release`: version claimed for the commit in `releases.toml`, draft `core-` created + - build jobs: 6 library zips uploaded + - `releases`: release published + - `publish`: `@simplex-chat/types` published, when its version is unpublished + - `record`: types version recorded + +Failed claim, with every build job skipped: +- version lower than the highest version recorded for the branch +- types changed, types version published +- types changed, `simplexChatCore` different from the core version + +After a failed claim, a fix commit is pushed; the version is claimed by the next push. + +Incomplete draft: completed by a re-run of the failed jobs in the run of the claim. The version is skipped by later pushes, as it is recorded for another commit. + +## Add-on release + +Run of `build.yml` on a push to master or stable with unreleased add-on sources: +- `maybe-release`: draft `nodejs-addon-<7 characters of the commit>` created, notes `sources: ` +- build jobs, 5 platforms: add-on built, tested with the latest library release of the branch, uploaded +- `releases`: release published + +Incomplete draft: completed by a re-run or by the next push with the same add-on sources. + +## Package release + +1. On master or stable, after `git pull`: + + ``` + python3 scripts/ci/packages_release.py prepare [nodejs] [python] + ``` + + Both packages when none is listed. + + Checked: + - published library release recorded for the branch + - add-on release published with every asset (Node.js) + - types version published on npm (Node.js) + - Python types unchanged since the commit of the pinned library release (Python) + - pinned releases published with the pinned SHA-256 + - every pinned asset downloaded with a matching SHA-256 + + Written: + - pins in `packages/simplex-chat-nodejs/src/release.ts` and `_version.py`: latest library release of the branch, add-on release of the current sources (Node.js), SHA-256 of every asset + - `@simplex-chat/types` dependency: `^` (Node.js) + - package versions: a published version bumped when the pins or the package files changed + + `--nodejs-version` and `--python-version` set the versions, for the first release of an app version, e.g. `--nodejs-version 7.1.0-beta.7.1 --python-version 7.1.0b7`. + +2. Commit, push to the same branch. + +3. Run of `build.yml`: + - build jobs, 5 platforms: packages tested, Node.js with the pinned add-on + - `publish`: package tests of 5 platforms and pins checked, unpublished versions published with trusted publishing + - `record`: published versions recorded + +Versions unpublished after a failure: published by the next push to the branch. + +## Local publication + +1. Step 1 of the package release. + +2. Package tests in a Python virtual environment, on the local platform (`linux-x86_64`, `linux-aarch64`, `macos-aarch64`, `macos-x86_64` or `windows-x86_64`): + + ``` + scripts/ci/package-tests.sh publish nodejs python + ``` + +3. In `packages/simplex-chat-nodejs`, with the npm tag of the version: + + ``` + npm publish --access public --tag beta + ``` + +4. In `packages/simplex-chat-python`: + + ``` + python -m pip install build twine + python -m build + twine upload dist/simplex_chat-* + ``` + +5. Commit, push to the same branch. Locally published versions are skipped by `build.yml` and are not recorded in `releases.toml`. + +## Settings + +- simplex-chat-libs: release immutability enabled +- secret `NODEJS_REPO_TOKEN`: contents of simplex-chat-libs, read and write +- trusted publisher of `simplex-chat` and `@simplex-chat/types` on npm and of `simplex-chat` on PyPI: workflow `build.yml` +- secrets `NPM_TOKEN` and `PYPI_API_TOKEN`: used instead of trusted publishing when set +- variables `NPM_SCOPE`, `PYPI_PROJECT`, `PYPI_URL` and `PYPI_UPLOAD_URL`: publication under other names, for test repositories + +## Pull requests + +- `packages.yml`, for package changes: + - core unchanged: add-on built and packages tested on 5 platforms + - core changed: add-on built on 5 platforms when its sources changed +- `build.yml`, for core changes: package tests on Linux x86_64 with the library built from the pull request diff --git a/packages/simplex-chat-client/types/typescript/README.md b/packages/simplex-chat-client/types/typescript/README.md index e30cf0d0c3..8d5f88657d 100644 --- a/packages/simplex-chat-client/types/typescript/README.md +++ b/packages/simplex-chat-client/types/typescript/README.md @@ -4,6 +4,8 @@ This TypeScript library provides auto-generated types for bots API: commands and It is used in [simplex-chat](https://www.npmjs.com/package/simplex-chat) Node.js library. +SimpleX Chat core: 7.1.0.12 + [API reference](https://github.com/simplex-chat/simplex-chat/tree/stable/bots). ## License diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 7bf43e0840..983292abd1 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,7 @@ { "name": "@simplex-chat/types", - "version": "0.12.1", + "version": "7.1.0-beta.6.1", + "simplexChatCore": "7.1.0.12", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 67eefdee4a..eaf9de68fb 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.6.3", + "version": "7.1.0-beta.6.4", "main": "dist/index.js", "types": "dist/index.d.ts", "gypfile": false, @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.12.1", + "@simplex-chat/types": "^7.1.0-beta.6.1", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3" }, diff --git a/packages/simplex-chat-nodejs/src/release.ts b/packages/simplex-chat-nodejs/src/release.ts index b56435e210..ad9dfa4c78 100644 --- a/packages/simplex-chat-nodejs/src/release.ts +++ b/packages/simplex-chat-nodejs/src/release.ts @@ -1,16 +1,16 @@ // Written by scripts/ci/packages_release.py from the simplex-chat-libs releases. -export const LIBS_RELEASE = "core-7.1.0.11" +export const LIBS_RELEASE = "core-7.1.0.12" 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-libs-linux-aarch64.zip": "924b8e14eaba5e309fa584c4bfe598421cf64983d63790c1c45247f592f60055", + "simplex-chat-libs-linux-x86_64-postgres.zip": "323f908b45dde751d6082f8885d07d81fba8a5ab8b2df7d3b8ab3a87e56299e5", + "simplex-chat-libs-linux-x86_64.zip": "995409e963db408bcf896361b669944484f845b6744fefe479aebb0ca2e7c5cd", + "simplex-chat-libs-macos-aarch64.zip": "64464aa48b996e5a96b4aa651bc57bc69e412d3c3804b589c67bd9dc52faddbf", + "simplex-chat-libs-macos-x86_64.zip": "94664f96ff7a2cd5a419096e7e843928faa31cb3addb8869fa1e4b5a0d0b3629", + "simplex-chat-libs-windows-x86_64.zip": "2e10844e5c119921139c373ee2971341aceef7dc4198429d3be6b1570a009d15", "simplex-chat-nodejs-linux-aarch64.node": "3636a82c56c48229e0326fa03d6ceb5c16dd3afbcc1e86337783dc53a7328d67", "simplex-chat-nodejs-linux-x86_64.node": "55f3479b03fdb57591179ed730da744f1aa362e7529df55221b60028415746e9", "simplex-chat-nodejs-macos-aarch64.node": "a3472872fb0816f435f52ee5471088e6545ebffb95e89fbf8e1ab82275f2d56e", diff --git a/packages/simplex-chat-python/README.md b/packages/simplex-chat-python/README.md index b86886d591..aa892d32ab 100644 --- a/packages/simplex-chat-python/README.md +++ b/packages/simplex-chat-python/README.md @@ -56,15 +56,6 @@ ruff check && pyright && pytest tests/ Wire types under `src/simplex_chat/types/_*.py` are generated. Regenerate with `cabal test simplex-chat-test --test-options='--match Python'`. -## Release - -Manual for now. Bump `_version.py:__version__`, build a wheel, upload to PyPI: - -```bash -uv build --wheel -uv publish --token "$PYPI_TOKEN" -``` - ## License [AGPL-3.0](./LICENSE) diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 0786a81795..e5075096d4 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -1,16 +1,16 @@ """Package version and the simplex-chat-libs release it loads. -Written by scripts/ci/packages_release.py: the release PR bumps the version +Written by scripts/ci/packages_release.py prepare: it 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_RELEASE = "core-7.1.0.11" # simplex-chat-libs release tag +__version__ = "7.1.0b6.post1" # PEP 440 — read by hatchling for wheel metadata +LIBS_RELEASE = "core-7.1.0.12" # 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", + "simplex-chat-libs-linux-aarch64.zip": "924b8e14eaba5e309fa584c4bfe598421cf64983d63790c1c45247f592f60055", + "simplex-chat-libs-linux-x86_64-postgres.zip": "323f908b45dde751d6082f8885d07d81fba8a5ab8b2df7d3b8ab3a87e56299e5", + "simplex-chat-libs-linux-x86_64.zip": "995409e963db408bcf896361b669944484f845b6744fefe479aebb0ca2e7c5cd", + "simplex-chat-libs-macos-aarch64.zip": "64464aa48b996e5a96b4aa651bc57bc69e412d3c3804b589c67bd9dc52faddbf", + "simplex-chat-libs-macos-x86_64.zip": "94664f96ff7a2cd5a419096e7e843928faa31cb3addb8869fa1e4b5a0d0b3629", + "simplex-chat-libs-windows-x86_64.zip": "2e10844e5c119921139c373ee2971341aceef7dc4198429d3be6b1570a009d15", } diff --git a/plans/2026-10-18-package-releases.md b/plans/2026-10-18-package-releases.md new file mode 100644 index 0000000000..286ab2cf26 --- /dev/null +++ b/plans/2026-10-18-package-releases.md @@ -0,0 +1,364 @@ +# Package releases + +Repository: simplex-chat/simplex-chat. +Prerequisite: PR #7750 merged; master runs are not cancelled. +Scope: releases in simplex-chat-libs, npm and PyPI. +App releases (`v*` tags in simplex-chat): unchanged. + +## Releases + +| Release | Repository | Name | Released when | Assets | +|---|---|---|---|---| +| libsimplex | simplex-chat-libs | `core-` | push to master or stable claims the version for its branch | `LIBS_ASSETS` | +| `@simplex-chat/types` | npm | version of its `package.json` | library release push, version absent from npm | | +| add-on | simplex-chat-libs | `nodejs-addon-<7 characters of the pushed commit sha>` | push to master or stable, published release of the add-on hash absent | `ADDON_ASSETS` | +| `simplex-chat` (npm) | npm | version of its `package.json` | push to master or stable, version absent from npm | | +| `simplex-chat` (PyPI) | PyPI | `__version__` of `_version.py` | push to master or stable, version absent from PyPI | | + +Release states in simplex-chat-libs: + +``` +absent +draft created before the build jobs; assets uploaded by the build jobs +published every asset present; immutable +``` + +Add-on hash: output of the current `addon_tag` function without its prefix. +Add-on release notes: `sources: `. + +Types source: `packages/simplex-chat-client/types/typescript/src`. +Python types source: `packages/simplex-chat-python/src/simplex_chat/types`. + +## Library claim + +Claim: entry `core..` = simplex-chat commit in `releases.toml` of simplex-chat-libs. +Branches: master, stable. +Written through the contents API with the blob sha; a concurrent writer re-reads after 409. + +Rows in order of evaluation: + +| `core..` | `claim-core` result | +|---|---| +| version lower than the highest version recorded for the branch | exit 1 | +| version recorded on any branch for this commit | `resume` | +| version recorded on any branch for another commit | `skip` | +| absent, types check failed | exit 1 | +| absent, types check passed | `new`: entry written | + +Previous core release: commit of the highest version recorded for the branch. + +Types check: + +``` +types changed types source differs from the previous core release +passed types unchanged + or types version unrecorded, absent from npm, simplexChatCore = +``` + +## Types versions + +``` +scheme ., as simplex-chat (npm) +first 7.1.0-beta.6.1, from core-release --types-version +next bump_npm_version of the types version at the previous core release +npm tag dist_tag: beta for a prerelease version, latest otherwise +``` + +## Manual settings + +1. simplex-chat-libs: immutable releases enabled. +2. npm trusted publisher of `simplex-chat`: workflow `build.yml`. +3. npm trusted publisher of `@simplex-chat/types`: workflow `build.yml`. +4. PyPI trusted publisher of `simplex-chat`: workflow `build.yml`. +5. Secret `RELEASE_PR_TOKEN`: deleted. +6. PR #7732: closed; branch `packages-release/master`: deleted. +7. `releases.toml` entry added: `core.master."7.1.0.11"` = `9711dcfc9c39ba125c1fb1674a3e039b1b9c0e81`. +8. Notes of release `nodejs-addon-308c0960c707`: `sources: 308c0960c707`. + +## Files + +- `scripts/ci/packages_release.py` +- `scripts/ci/test_packages_release.py` +- `scripts/ci/package-tests.sh`: new. +- `.github/workflows/build.yml` +- `.github/workflows/packages.yml` +- `packages/simplex-chat-client/types/typescript/package.json`: field `simplexChatCore`, `7.1.0.11`. +- `packages/simplex-chat-client/types/typescript/README.md`: line `SimpleX Chat core: 7.1.0.11`. + +## packages_release.py + +Asset set by tag prefix: + +``` +core- LIBS_ASSETS +nodejs-addon- ADDON_ASSETS +``` + +Commands: + +``` +core-release [--branch ] [--types-version ] + run locally + branch: the checked out branch by default + version not higher than the highest version recorded for the branch: exit 1 + version: of simplex-chat.cabal written + types changed or --types-version: types version written, as in Types versions + types version written: simplexChatCore and the README line written +claim-core + run in maybe-release + Library claim; prints claim=new, claim=resume or claim=skip + new: also prints commit= +release-status + prints status=published, status=draft or status=absent + drafts: from the authenticated release list +addon-release + releases nodejs-addon-* with notes "sources: " + published one: prints tag= and status=published + draft: prints tag= and status=draft + neither: prints status=absent +latest-libs [--branch ] + prints tag= + order: numeric components of the version +publish-release + draft with every asset of its set: published + missing assets: listed, exit 1 +check-pins + pins of release.ts and _version.py compared with the digests of published releases + mismatch, missing asset or unpublished release: listed, exit 1 +download + asset saved to / + SHA-256 compared with the API digest; mismatch: exit 1 +download-pinned + tag and SHA-256 from release.ts + asset saved to /; mismatch: exit 1 +prepare [nodejs] [python] [--branch ] + run locally + branch: the checked out branch by default + nodejs: simplex-chat (npm); python: simplex-chat (PyPI) + packages: the listed ones; all when none is listed + other package: pins and version unchanged + library pin: latest-libs of the branch + warning: core- newer than the pinned release + nodejs add-on pin: addon-release; exit 1 unless published with every asset + nodejs types dependency: ^; exit 1 unless published on npm + python: exit 1 when the Python types source differs from the commit recorded for the pinned core version + every pinned asset: download, SHA-256 compared with the API digest + changed package: directory without tests differs from the commit that set its version + versions: bumped as now; --nodejs-version, --python-version override + written: release.ts, _version.py, package.json of simplex-chat (npm) + summary: printed; --summary and --types-version removed +build-plan + pull requests only + --event and --release-missing removed +``` + +Commands removed: `addon-tag`. +`record`: option `--branch` of `--core`, default master; replaced by `claim-core` in maybe-release. +Unchanged: `changes`, `publish-plan`, `test-names`. + +`build_plan(core_changed, addon_changed)`: + +``` +core changed, add-on changed platforms: all; test: false +core changed, add-on unchanged platforms: none; test: false +core unchanged platforms: all; test: true +``` + +## package-tests.sh + +Commands from the build steps of `packages.yml` (lines 147-166). + +``` +build + types, npm package and add-on built + add-on copied to packages/simplex-chat-nodejs/build/simplex-chat-nodejs-.node +test + jest, as in packages.yml; SIMPLEX_LIBS_DIR and SIMPLEX_ADDON_PATH from the environment + pytest +addon + build + simplex-chat-libs-.zip of $LIBS_RELEASE: packages_release.py download, unpack to $RUNNER_TEMP/libs + jest with SIMPLEX_LIBS_DIR=$RUNNER_TEMP/libs/libs + add-on uploaded to draft $ADDON_TAG of $LIBS_REPOSITORY: gh release upload --clobber, token $GH_TOKEN +publish [nodejs] [python] + nodejs: types and npm package built + download-pinned simplex-chat-nodejs-.node to $RUNNER_TEMP/addon + add-on copied to packages/simplex-chat-nodejs/build/Release/simplex.node, the path of the tests + jest + python: pytest + run in CI and locally +``` + +## build.yml + +### maybe-release + +Outputs: + +``` +libs_tag core-, when the claim is new or resume +addon_tag tag of the add-on draft +addon_draft true when the add-on release is a draft after this job +libs_latest output tag of latest-libs +publish_types libs_tag set, types version printed by publish-plan +publish_nodejs nodejs version printed by publish-plan +publish_python python version printed by publish-plan +publish publish_nodejs or publish_python +``` + +Steps after "Create release": + +| Step | Condition | Action | +|---|---|---| +| Library release | push to master or stable | `claim-core `; new → `gh release create core- --draft --target `; resume → existing draft, `libs_tag` empty when published; skip → notice annotation | +| Add-on release | push to master or stable | `addon-release`: absent → `gh release create nodejs-addon-<7 characters of github.sha> --draft --notes "sources: "`; draft → reused; output `addon_draft` true when absent or draft | +| Latest library | `addon_draft` | `latest-libs --branch ` | +| Versions to publish | push to master or stable | `publish-plan`; environment `NPM_SCOPE`, `PYPI_PROJECT`, `PYPI_URL` as in `packages.yml` | + +Steps removed: "Check version change", "Record libs release", "Create libs release". +"Clone project": `fetch-depth: 2`, unchanged; the types check fetches the commit of the recorded types version. +Token of the release steps: `NODEJS_REPO_TOKEN`. +Library release jobs: concurrency groups with `libs_tag`, unchanged. + +### Library uploads + +Steps "Upload library" (build-linux, build-macos, build-windows) and "Upload postgres library": + +``` +action gh release upload --clobber --repo -libs +token NODEJS_REPO_TOKEN +conditions unchanged +``` + +### Package steps in build jobs + +Platform jobs: + +``` +build-linux matrix.libs entry linux-x86_64 +build-linux-push matrix.libs entry linux-aarch64 +build-macos both entries macos- +build-windows windows-latest windows-x86_64 +``` + +Steps, after "Run tests" (build-linux, build-macos) and after "Upload library" (build-windows): + +| Step | Condition | +|---|---| +| Setup Node.js 22 | `!cancelled()`; core pull request condition, `addon_draft` or `publish` | +| Setup Python 3.12 | `!cancelled()`, Setup Node.js succeeded | +| Package tests (pull request) | build-linux only, as now; `package-tests.sh build linux-x86_64`, then `test` with SIMPLEX_LIBS_DIR | +| Add-on | `!cancelled()`, `addon_draft`, Setup Python succeeded; `package-tests.sh addon ` | +| Package tests | `!cancelled()`, `publish`, Setup Python succeeded; `package-tests.sh publish `, with `nodejs` when `publish_nodejs`, with `python` when `publish_python` | +| Package tests result | Package tests succeeded; artifact `package-tests-` | + +Linux steps: on the host. + +Environment of "Add-on": + +``` +LIBS_RELEASE libs_latest +ADDON_TAG addon_tag +LIBS_REPOSITORY -libs +GH_TOKEN NODEJS_REPO_TOKEN +``` + +### Final jobs + +``` +releases + needs maybe-release, build-linux, build-linux-push, build-linux-postgres, build-macos, build-windows + if !cancelled(), libs_tag or addon_draft + step 1 publish-release ; libs_tag + step 2 publish-release ; !cancelled(), addon_draft + token NODEJS_REPO_TOKEN +publish + needs maybe-release, build-linux, build-linux-push, build-macos, build-windows, releases + if !cancelled(), publish or publish_types + permissions contents: read; id-token: write + env as in packages.yml: NODE_AUTH_TOKEN, NPM_SCOPE, PYPI_PROJECT, PYPI_URL + step 1 when publish: artifacts package-tests-*, 5 required + step 2 when publish: check-pins + steps "publish" job of packages.yml, from "Versions to publish" + types step publish_types and needs.releases.result == 'success' +record + as in packages.yml; needs publish +``` + +Comments of the `publish` job of `packages.yml`: moved with it. +Comment of the `record` job: removed. +Trusted publishing comment (`packages.yml` lines 12-15): moved above `publish`. + +Job removed: `packages`, with its header comment. + +## packages.yml + +``` +triggers pull_request, paths unchanged + push, workflow_dispatch, workflow_call: removed +addon job steps "Add-on release tag", "Release branch", "Check the add-on release": removed + outputs tag, release_branch, missing: removed + Build plan: --event and --release-missing removed +build job Checkout, Setup Node.js, Setup Python + package-tests.sh build + package-tests.sh test, when planned + "Upload add-on artifact": removed +jobs removed publish, record, release +header lines 3-10 and 17-28: removed +``` + +## Release procedure + +Library and types: + +1. `python3 scripts/ci/packages_release.py core-release ` on master or stable, with `--types-version` for a new app version. +2. Commit of the written files; push to the same branch. +3. `build.yml`: claim, draft, uploads by the build jobs, `releases`, types publication. +4. Failed claim: fix commit; the next push claims the version. +5. Incomplete draft: re-run of the failed build jobs, then of `releases` and `publish`. + +Add-on: + +1. Push to master or stable changes the add-on sources. +2. Incomplete draft: completed by a re-run or by the next push. + +Packages, published by GitHub: + +1. `git pull` on master or stable. +2. `python3 scripts/ci/packages_release.py prepare`, with package names and version overrides when needed. +3. Commit of the written files; push to the same branch. +4. Versions absent from the registries: published by `build.yml`. + +Packages, published locally: + +1. Steps 1 and 2 above. +2. `scripts/ci/package-tests.sh publish [nodejs] [python]`. +3. `npm publish` of `simplex-chat`; `python -m build` and `twine upload` of the Python package. +4. Step 3 above; `build.yml` skips the published versions. + +Published npm package: `files` of `package.json` and the pins; add-on and libsimplex downloaded at run time. + +## Verification + +1. Unit tests: + - `claim-core`: every row of the Library claim table + - types check: changed and unchanged types, recorded and unrecorded types version + - `core-release` with and without types changes + - `release-status` + - `addon-release` notes lookup + - `prepare` with one package listed + - `prepare python` with changed Python types + - `latest-libs` order + - `publish-release` with missing assets + - `check-pins` mismatch + - `download` digest mismatch + - `build_plan` cases +2. `gh release upload` to a draft by tag: `shared.FetchRelease` of gh looks up the draft by tag (`fetchDraftRelease`). +3. First library release after the merge: assets uploaded to the draft, release published by `publish-release`. +4. First master push after merge: claim `skip`, as `core-7.1.0.11` is recorded for `9711dcfc`; `addon-release` finds `nodejs-addon-308c0960c707`; package steps skipped. +5. Local `prepare`: pins `core-7.1.0.11` and `nodejs-addon-308c0960c707`; every download matches. +6. `core-release 7.1.0.12 --types-version 7.1.0-beta.6.1`, commit, push: claim `new`, draft, 6 assets uploaded, release published, types 7.1.0-beta.6.1 published with tag beta. +7. Master push with an add-on source change: draft created, 5 assets uploaded, release published. +8. Master push with versions from `prepare`: 5 artifacts `package-tests-*`, pins checked, versions published, versions recorded. +9. Second push during the run of step 6: claim `skip`. +10. Stable push after master is merged into stable, with a stable core version: claim `new` for `core.stable`. diff --git a/scripts/ci/package-tests.sh b/scripts/ci/package-tests.sh new file mode 100755 index 0000000000..33d83c4986 --- /dev/null +++ b/scripts/ci/package-tests.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +set -euo pipefail + +cd "$(dirname "$0")/../.." +nodejs=packages/simplex-chat-nodejs +python_package=packages/simplex-chat-python +python=$(command -v python3 || command -v python) +temp=${RUNNER_TEMP:-$(mktemp -d)} + +usage() { + echo "usage: $0 build | test | addon | publish [nodejs] [python]" >&2 + exit 1 +} + +build_package() { + (cd packages/simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) + (cd "$nodejs" && npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript) + if [ -n "${1:-}" ]; then + (cd "$nodejs" && npx --yes node-gyp rebuild) + fi + (cd "$nodejs" && npx tsc && cp src/simplex.* dist) + if [ -n "${1:-}" ]; then + cp "$nodejs/build/Release/simplex.node" "$nodejs/build/simplex-chat-nodejs-$1.node" + fi +} + +test_nodejs() { + (cd "$nodejs" && npx jest --forceExit --testNamePattern '^(?!.*use preset servers)') +} + +test_python() { + (unset SIMPLEX_LIBS_DIR && cd "$python_package" && "$python" -m pip install -e ".[test]" && "$python" -m pytest -q) +} + +case "${1:-}" in + build) + [ $# -eq 2 ] || usage + build_package "$2" + ;; + test) + [ $# -eq 1 ] || usage + test_nodejs + test_python + ;; + addon) + [ $# -eq 2 ] || usage + build_package "$2" + zip=simplex-chat-libs-$2.zip + "$python" scripts/ci/packages_release.py download "$LIBS_RELEASE" "$zip" "$temp/libs" + "$python" -m zipfile -e "$temp/libs/$zip" "$temp/libs" + SIMPLEX_LIBS_DIR=$temp/libs/libs test_nodejs + gh release upload "$ADDON_TAG" "$nodejs/build/simplex-chat-nodejs-$2.node" --clobber --repo "$LIBS_REPOSITORY" + ;; + publish) + [ $# -ge 2 ] || usage + platform=$2 + shift 2 + for suite in "$@"; do + case "$suite" in + nodejs) + build_package + addon=simplex-chat-nodejs-$platform.node + "$python" scripts/ci/packages_release.py download-pinned "$addon" "$temp/addon" + mkdir -p "$nodejs/build/Release" + cp "$temp/addon/$addon" "$nodejs/build/Release/simplex.node" + test_nodejs + ;; + python) + test_python + ;; + *) + usage + ;; + esac + done + ;; + *) + usage + ;; +esac diff --git a/scripts/ci/packages_release.py b/scripts/ci/packages_release.py index b13b9cc00d..fdfd7c3b68 100644 --- a/scripts/ci/packages_release.py +++ b/scripts/ci/packages_release.py @@ -1,13 +1,21 @@ #!/usr/bin/env python3 """Release automation for simplex-chat and @simplex-chat/types on npm and simplex-chat on PyPI. -addon-tag print the add-on release tag for the current sources -changes print whether the changes since a commit touch the chat core and the add-on -build-plan print the platforms to build the add-on on and whether to run the package tests -prepare update pins and versions for a release PR -publish-plan print the package versions that are not published yet -record record releases and published versions in releases.toml of simplex-chat-libs -test-names rename the packages to NPM_SCOPE and PYPI_PROJECT (test repositories) +core-release set the core version and the types version for a library release +claim-core claim the library release of the core version for a commit +release-status print whether a release of simplex-chat-libs is published, a draft or absent +addon-release print the add-on release of the current add-on sources +latest-libs print the newest published library release +publish-release publish a draft release that has every asset +check-pins check the pins of the packages against the published releases +download download a release asset and check its digest +download-pinned download a pinned asset and check its pin +changes print whether the changes since a commit touch the chat core and the add-on +build-plan print the platforms to build the add-on on and whether to run the package tests +prepare update pins and versions of the packages +publish-plan print the package versions that are not published yet +record record releases and published versions in releases.toml of simplex-chat-libs +test-names rename the packages to NPM_SCOPE and PYPI_PROJECT (test repositories) """ from __future__ import annotations @@ -19,12 +27,14 @@ import hashlib import json import os import re +import shlex import subprocess import sys +import tempfile import tomllib import urllib.error import urllib.request -from collections.abc import Callable +from collections.abc import Callable, Collection from dataclasses import dataclass, field, replace from typing import Any from pathlib import Path @@ -37,7 +47,15 @@ RELEASE_FILE = f"{NODEJS_DIR}/src/release.ts" PYTHON_VERSION_FILE = f"{PYTHON_DIR}/src/simplex_chat/_version.py" ADDON_SOURCES = [f"{NODEJS_DIR}/binding.gyp", f"{NODEJS_DIR}/cpp"] CORE_SOURCES = ["src", "simplex-chat.cabal", "cabal.project"] -ADDON_TAG_HASH_LENGTH = 12 +CABAL_FILE = "simplex-chat.cabal" +CABAL_VERSION = re.compile(r"^(version:\s*)(\S+)", re.M) +TYPES_SOURCES = [f"{TYPES_DIR}/src"] +TYPES_README = f"{TYPES_DIR}/README.md" +TYPES_README_CORE = re.compile(r"^SimpleX Chat core: .*$", re.M) +PYTHON_TYPES_SOURCES = [f"{PYTHON_DIR}/src/simplex_chat/types"] +ADDON_HASH_LENGTH = 12 +CORE_TAG = re.compile(r"^core-(\d+(?:\.\d+)*)$") +ADDON_TAG_PREFIX = "nodejs-addon-" LIBS_ASSETS = [ "simplex-chat-libs-linux-aarch64.zip", "simplex-chat-libs-linux-x86_64-postgres.zip", @@ -58,18 +76,27 @@ ADDON_ASSETS = [f"simplex-chat-nodejs-{platform}.node" for platform in ADDON_PLA SHA256_HEX = re.compile(r"^[0-9a-f]{64}$") RELEASES_FILE = "releases.toml" RECORD_ATTEMPTS = 5 +DOWNLOAD_CHUNK = 1 << 20 TOML_BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$") # Pure functions -def addon_tag(files: dict[str, bytes], node_addon_api: str) -> str: +def addon_hash(files: dict[str, bytes], node_addon_api: str) -> str: digest = hashlib.sha256() for name in sorted(files): digest.update(name.encode() + b"\0" + files[name] + b"\0") digest.update(f"node-addon-api@{node_addon_api}".encode()) - return f"nodejs-addon-{digest.hexdigest()[:ADDON_TAG_HASH_LENGTH]}" + return digest.hexdigest()[:ADDON_HASH_LENGTH] + + +def addon_notes(sources_hash: str) -> str: + return f"sources: {sources_hash}" + + +def version_key(version: str) -> tuple[int, ...]: + return tuple(int(part) for part in version.split(".")) def bump_npm_version(version: str) -> str: @@ -141,7 +168,7 @@ def render_python_version(version: str, pins: Pins) -> str: return ( '"""Package version and the simplex-chat-libs release it loads.\n' "\n" - "Written by scripts/ci/packages_release.py: the release PR bumps the version\n" + "Written by scripts/ci/packages_release.py prepare: it bumps the version\n" "and pins new library releases with the SHA-256 of their assets.\n" '"""\n' "\n" @@ -152,16 +179,84 @@ def render_python_version(version: str, pins: Pins) -> str: ) +def asset_digests(release: dict) -> dict[str, str]: + return {a["name"]: (a.get("digest") or "").removeprefix("sha256:") for a in release["assets"]} + + def release_digests(release: dict | None, assets: list[str]) -> dict[str, str] | None: """Asset digests of a GitHub release, or None when the release is missing or incomplete.""" if release is None: return None - digests = {a["name"]: (a.get("digest") or "").removeprefix("sha256:") for a in release["assets"]} + digests = asset_digests(release) if not all(SHA256_HEX.match(digests.get(asset, "")) for asset in assets): return None return {asset: digests[asset] for asset in assets} +def release_assets(tag: str) -> list[str]: + if CORE_TAG.match(tag): + return LIBS_ASSETS + if tag.startswith(ADDON_TAG_PREFIX): + return ADDON_ASSETS + raise ValueError(f"no asset set for release {tag}") + + +def preferred_release(releases: list[dict]) -> dict | None: + """The published release among them, otherwise the first draft.""" + return next((r for r in releases if not r["draft"]), next(iter(releases), None)) + + +def release_status(release: dict | None) -> str: + if release is None: + return "absent" + return "draft" if release["draft"] else "published" + + +def tag_release(releases: list[dict], tag: str) -> dict | None: + return preferred_release([r for r in releases if r["tag_name"] == tag]) + + +def addon_release(releases: list[dict], sources_hash: str) -> dict | None: + notes = addon_notes(sources_hash) + return preferred_release( + [r for r in releases if r["tag_name"].startswith(ADDON_TAG_PREFIX) and r.get("body") == notes] + ) + + +def core_version(tag: str) -> str | None: + match = CORE_TAG.match(tag) + return match[1] if match else None + + +def latest_libs(releases: list[dict], versions: Collection[str]) -> str | None: + """The newest published release of the core versions that has every library asset.""" + tags = [ + r["tag_name"] + for r in releases + if not r["draft"] and core_version(r["tag_name"]) in versions and release_digests(r, LIBS_ASSETS) + ] + return max(tags, key=lambda tag: version_key(core_version(tag)), default=None) + + +def missing_assets(release: dict) -> list[str]: + present = {a["name"] for a in release["assets"]} + return [asset for asset in release_assets(release["tag_name"]) if asset not in present] + + +def pin_problems(pins: Pins, published: dict[str, dict | None]) -> list[str]: + """Differences between the pins and the published releases of the pinned tags.""" + problems = [] + for tag, release in published.items(): + if release is None: + problems.append(f"{tag} is not published") + continue + digests = asset_digests(release) + for asset in release_assets(tag): + if pins.sha256.get(asset) != digests.get(asset): + problems.append(f"{asset} of {tag}: pinned {pins.sha256.get(asset)}, published {digests.get(asset)}") + return problems + + @dataclass(frozen=True) class Package: version: str @@ -177,10 +272,11 @@ class State: addon_digests: dict[str, str] | None nodejs_pins: Pins python_pins: Pins - types: Package + types_dependency: str + nodejs_types_dependency: str nodejs: Package python: Package - types_version: str | None = None + packages: frozenset[str] = frozenset({"nodejs", "python"}) nodejs_version: str | None = None python_version: str | None = None @@ -189,23 +285,16 @@ class State: class Plan: nodejs_pins: Pins python_pins: Pins - types_version: str - types_bumped: bool + types_dependency: str | None nodejs_version: str nodejs_changed: bool python_version: str python_changed: bool notes: list[str] = field(default_factory=list) - @property - def types_dependency(self) -> str | None: - return f"^{self.types_version}" if self.types_bumped else None - @property def title(self) -> str: parts = [] - if self.types_bumped: - parts.append(f"types {self.types_version}") if self.nodejs_changed: parts.append(f"nodejs {self.nodejs_version}") if self.python_changed: @@ -214,7 +303,7 @@ class Plan: def plan(state: State) -> Plan: - """What a release PR changes, from the state of the branch and the releases.""" + """What a package release changes, from the state of the branch and the releases.""" notes: list[str] = [] def pin(pins: Pins, field_name: str, tag: str, digests: dict[str, str] | None, what: str) -> tuple[Pins, bool]: @@ -227,27 +316,30 @@ def plan(state: State) -> Plan: notes.append(f"{what}: {tag}") return replace(pins, **{field_name: tag, "sha256": {**pins.sha256, **digests}}), True - nodejs_pins, nodejs_libs = pin( - state.nodejs_pins, "libs_release", state.libs_tag, state.libs_digests, "Node.js libsimplex" - ) - nodejs_pins, addon = pin(nodejs_pins, "addon_release", state.addon_tag, state.addon_digests, "Node.js add-on") - python_pins, python_libs = pin( - state.python_pins, "libs_release", state.libs_tag, state.libs_digests, "Python libsimplex" - ) - def next_version(pkg: Package, changed: bool, override: str | None, bump) -> str: if override: return override return bump(pkg.version) if changed and pkg.published else pkg.version - types_version = next_version(state.types, state.types.changed, state.types_version, bump_npm_version) - types_bumped = types_version != state.types.version - nodejs_changed = nodejs_libs or addon or types_bumped or state.nodejs.changed - nodejs_version = next_version(state.nodejs, nodejs_changed, state.nodejs_version, bump_npm_version) - python_changed = python_libs or state.python.changed - python_version = next_version(state.python, python_changed, state.python_version, bump_python_version) + nodejs_pins, nodejs_version, nodejs_changed, types_dependency = state.nodejs_pins, state.nodejs.version, False, None + if "nodejs" in state.packages: + nodejs_pins, nodejs_libs = pin( + state.nodejs_pins, "libs_release", state.libs_tag, state.libs_digests, "Node.js libsimplex" + ) + nodejs_pins, addon = pin(nodejs_pins, "addon_release", state.addon_tag, state.addon_digests, "Node.js add-on") + if state.types_dependency != state.nodejs_types_dependency: + types_dependency = state.types_dependency + notes.append(f"@simplex-chat/types: {types_dependency}") + nodejs_changed = nodejs_libs or addon or types_dependency is not None or state.nodejs.changed + nodejs_version = next_version(state.nodejs, nodejs_changed, state.nodejs_version, bump_npm_version) + python_pins, python_version, python_changed = state.python_pins, state.python.version, False + if "python" in state.packages: + python_pins, python_libs = pin( + state.python_pins, "libs_release", state.libs_tag, state.libs_digests, "Python libsimplex" + ) + python_changed = python_libs or state.python.changed + python_version = next_version(state.python, python_changed, state.python_version, bump_python_version) for name, old, new in [ - ("@simplex-chat/types", state.types.version, types_version), ("simplex-chat (npm)", state.nodejs.version, nodejs_version), ("simplex-chat (PyPI)", state.python.version, python_version), ]: @@ -256,8 +348,7 @@ def plan(state: State) -> Plan: return Plan( nodejs_pins=nodejs_pins, python_pins=python_pins, - types_version=types_version, - types_bumped=types_bumped, + types_dependency=types_dependency, nodejs_version=nodejs_version, nodejs_changed=nodejs_changed or nodejs_version != state.nodejs.version, python_version=python_version, @@ -272,31 +363,49 @@ class BuildPlan: test: bool -def build_plan( - event: str, release_missing: bool, libs_release: str, core_changed: bool, addon_changed: bool -) -> BuildPlan: - """Each package test runs once, against the libsimplex of the change: a pull request that changes the core - is tested by build.yml with its own libsimplex, a new library release with that release.""" - if libs_release: - return BuildPlan(ADDON_PLATFORMS, test=True) - if event == "pull_request": - if core_changed: - return BuildPlan(ADDON_PLATFORMS if addon_changed else [], test=False) - return BuildPlan(ADDON_PLATFORMS, test=True) - return BuildPlan(ADDON_PLATFORMS if release_missing else [], test=True) +def build_plan(core_changed: bool, addon_changed: bool) -> BuildPlan: + """The package tests of a pull request that changes the core run in build.yml, with its own libsimplex.""" + if core_changed: + return BuildPlan(ADDON_PLATFORMS if addon_changed else [], test=False) + return BuildPlan(ADDON_PLATFORMS, test=True) def build_matrix(platforms: list[str]) -> str: return json.dumps([{"platform": platform, "os": ADDON_RUNNERS[platform]} for platform in platforms]) -def recorded_commit(releases: dict, key: str, version: str) -> str | None: - """The simplex-chat commit recorded for a version; key is "core", "npm/" or "pypi/".""" +def core_key(branch: str) -> str: + return f"core/{branch}" + + +def recorded_versions(releases: dict, key: str) -> dict[str, str]: + """The recorded versions and their simplex-chat commits; key is "core/", "npm/" or "pypi/".""" table = releases for part in key.split("/", 1): table = table.get(part, {}) - commit = table.get(version) - return commit if isinstance(commit, str) else None + return {version: commit for version, commit in table.items() if isinstance(commit, str)} + + +def recorded_commit(releases: dict, key: str, version: str) -> str | None: + return recorded_versions(releases, key).get(version) + + +def highest_recorded(releases: dict, key: str) -> str | None: + return max(recorded_versions(releases, key), key=version_key, default=None) + + +def recorded_core_commit(releases: dict, version: str) -> str | None: + """The commit recorded for the core version on any branch: a release tag is shared by the branches.""" + for branch in releases.get("core", {}): + if commit := recorded_commit(releases, core_key(branch), version): + return commit + return None + + +def previous_core_commit(releases: dict, branch: str) -> str | None: + """The commit of the highest core version recorded for the branch.""" + previous = highest_recorded(releases, core_key(branch)) + return recorded_commit(releases, core_key(branch), previous) if previous else None def add_releases(releases: dict, entries: list[tuple[str, str]], commit: str) -> dict: @@ -357,14 +466,18 @@ def write_json(file: str, value: dict) -> None: (ROOT / file).write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n") -def current_addon_tag() -> str: +def current_addon_hash() -> str: files = { path.relative_to(ROOT).as_posix(): path.read_bytes() for source in ADDON_SOURCES for path in ([ROOT / source] if (ROOT / source).is_file() else sorted((ROOT / source).rglob("*"))) if path.is_file() } - return addon_tag(files, read_json(f"{NODEJS_DIR}/package.json")["devDependencies"]["node-addon-api"]) + return addon_hash(files, read_json(f"{NODEJS_DIR}/package.json")["devDependencies"]["node-addon-api"]) + + +def cabal_version() -> str: + return CABAL_VERSION.search(read_text(CABAL_FILE))[2] def changed_since(base: str, sources: list[str]) -> bool: @@ -378,13 +491,9 @@ def changes(base: str) -> None: def print_build_plan(args: argparse.Namespace) -> None: - pull_request = args.event == "pull_request" result = build_plan( - args.event, - release_missing=args.release_missing, - libs_release=args.libs_release, - core_changed=pull_request and changed_since(args.base, CORE_SOURCES), - addon_changed=pull_request and changed_since(args.base, ADDON_SOURCES), + core_changed=changed_since(args.base, CORE_SOURCES), + addon_changed=changed_since(args.base, ADDON_SOURCES), ) print(f"platforms={build_matrix(result.platforms)}") print(f"test={'true' if result.test else ''}") @@ -429,10 +538,13 @@ def http_json(url: str, headers: dict[str, str] | None = None, method: str = "GE # Workflows pass unset repository variables as empty strings. -def npm_published(name: str, version: str) -> bool: +def npm_package_url(name: str) -> str: registry = os.environ.get("NPM_REGISTRY") or "https://registry.npmjs.org" - package = registry_name(name, os.environ.get("NPM_SCOPE")).replace("/", "%2F") - return http_json(f"{registry}/{package}/{version}") is not None + return f"{registry}/{registry_name(name, os.environ.get('NPM_SCOPE')).replace('/', '%2F')}" + + +def npm_published(name: str, version: str) -> bool: + return http_json(f"{npm_package_url(name)}/{version}") is not None def python_project() -> str: @@ -445,19 +557,50 @@ def pypi_published(version: str) -> bool: return http_json(f"{index}/pypi/{project}/{version}/json") is not None +def libs_repository() -> str: + return os.environ.get("LIBS_REPOSITORY") or "simplex-chat/simplex-chat-libs" + + def libs_api(path: str, method: str = "GET", body: dict | None = None) -> Any: """GitHub API of the simplex-chat-libs repository.""" - repo = os.environ.get("LIBS_REPOSITORY") or "simplex-chat/simplex-chat-libs" headers = {"Accept": "application/vnd.github+json"} if token := os.environ.get("GH_TOKEN"): headers["Authorization"] = f"Bearer {token}" - return http_json(f"https://api.github.com/repos/{repo}/{path}", headers, method, body) + return http_json(f"https://api.github.com/repos/{libs_repository()}/{path}", headers, method, body) def github_release(tag: str) -> dict | None: + """The published release of the tag.""" return libs_api(f"releases/tags/{tag}") +def libs_releases() -> list[dict]: + """The releases of simplex-chat-libs; drafts are listed only with a token that can push to it.""" + releases: list[dict] = [] + page = 1 + while batch := libs_api(f"releases?per_page=100&page={page}"): + releases += batch + page += 1 + return releases + + +def download(tag: str, asset: str, directory: str, sha256: str) -> Path: + """Saves the asset of the release to the directory; exits when its SHA-256 differs.""" + url = f"https://github.com/{libs_repository()}/releases/download/{tag}/{asset}" + target = Path(directory) / asset + target.parent.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256() + request = urllib.request.Request(url, headers={"User-Agent": "simplex-chat-release"}) + with urllib.request.urlopen(request, timeout=600) as response, target.open("wb") as file: + while chunk := response.read(DOWNLOAD_CHUNK): + file.write(chunk) + digest.update(chunk) + if digest.hexdigest() != sha256: + target.unlink() + sys.exit(f"SHA-256 of {asset} of {tag} is {digest.hexdigest()}, expected {sha256}") + return target + + def read_releases() -> tuple[dict, str | None]: """The releases and the blob of the file, which a change must name.""" file = libs_api(f"contents/{RELEASES_FILE}") @@ -495,6 +638,13 @@ def commit_exists(commit: str) -> bool: return found.returncode == 0 +def fetch_commit(commit: str) -> str: + """The commit, fetched from origin when the checkout lacks it.""" + if not commit_exists(commit): + git("fetch", "-q", "--depth=1", "origin", commit) + return commit + + def npm_key(directory: str) -> str: return f"npm/{read_json(f'{directory}/package.json')['name']}" @@ -517,21 +667,113 @@ def npm_package(directory: str, releases: dict) -> Package: ) -def prepare(args: argparse.Namespace) -> None: - cabal_version = read_field("simplex-chat.cabal", r"^version:\s*(\S+)") - libs_tag = f"core-{cabal_version}" - addon = current_addon_tag() - python_version, python_pins = parse_python_version(read_text(PYTHON_VERSION_FILE)) +def types_changed(releases: dict, branch: str) -> bool: + """Whether the types sources differ from those of the previous core release on the branch.""" + previous = previous_core_commit(releases, branch) + return previous is None or changed_since(fetch_commit(previous), TYPES_SOURCES) + + +def types_problem(version: str, releases: dict, branch: str) -> str | None: + """Why the types can't be released with core , or None when they can.""" + if not types_changed(releases, branch): + return None + types = read_json(f"{TYPES_DIR}/package.json") + if recorded_commit(releases, npm_key(TYPES_DIR), types["version"]) or npm_published(types["name"], types["version"]): + return f"{types['name']} changed, and its version {types['version']} is published" + if types.get("simplexChatCore") != version: + return f"simplexChatCore of {types['name']} is {types.get('simplexChatCore')}, expected {version}" + return None + + +def claim_core(version: str, commit: str, branch: str) -> None: releases, _ = read_releases() + highest = highest_recorded(releases, core_key(branch)) + if highest and version_key(version) < version_key(highest): + sys.exit(f"core {version} is lower than core {highest} recorded for {branch}") + if recorded := recorded_core_commit(releases, version): + print(f"claim={'resume' if recorded == commit else 'skip'}") + return + if problem := types_problem(version, releases, branch): + sys.exit(problem) + libs_commit = record_releases([(core_key(branch), version)], commit) + releases, _ = read_releases() + if recorded_core_commit(releases, version) == commit: + print("claim=new") + print(f"commit={libs_commit}") + else: + print("claim=skip") + + +def current_branch() -> str: + return git("rev-parse", "--abbrev-ref", "HEAD") + + +def core_release(version: str, branch: str, types_version: str | None) -> None: + releases, _ = read_releases() + highest = highest_recorded(releases, core_key(branch)) + if highest and version_key(version) <= version_key(highest): + sys.exit(f"core {version} is not higher than core {highest} recorded for {branch}") + (ROOT / CABAL_FILE).write_text(CABAL_VERSION.sub(rf"\g<1>{version}", read_text(CABAL_FILE), count=1)) + print(f"{CABAL_FILE}: {version}") + if not types_version and not types_changed(releases, branch): + return + types = read_json(f"{TYPES_DIR}/package.json") + if not types_version: + previous = previous_core_commit(releases, branch) + released = json.loads(git("show", f"{previous}:{TYPES_DIR}/package.json")) if previous else types + types_version = bump_npm_version(released["version"]) + readme, found = TYPES_README_CORE.subn(f"SimpleX Chat core: {version}", read_text(TYPES_README), count=1) + if not found: + sys.exit(f"{TYPES_README} has no line 'SimpleX Chat core: '") + write_json(f"{TYPES_DIR}/package.json", {**types, "version": types_version, "simplexChatCore": version}) + (ROOT / TYPES_README).write_text(readme) + print(f"{types['name']}: {types_version}") + + +def pinned_tags(pins: Pins) -> list[str]: + return [tag for tag in (pins.libs_release, pins.addon_release) if tag] + + +def published_pin_problems(pins: Pins) -> list[str]: + return pin_problems(pins, {tag: github_release(tag) for tag in pinned_tags(pins)}) + + +def prepare(args: argparse.Namespace) -> None: + packages = frozenset(args.packages or ["nodejs", "python"]) + branch = args.branch or current_branch() + releases, _ = read_releases() + libs = libs_releases() + libs_tag = latest_libs(libs, recorded_versions(releases, core_key(branch))) + if libs_tag is None: + sys.exit(f"no published library release recorded for {branch}") + core = core_version(libs_tag) + if version_key(cabal_version()) > version_key(core): + print(f"warning: core-{cabal_version()} is not published, {libs_tag} is pinned", file=sys.stderr) + sources_hash = current_addon_hash() + addon = addon_release(libs, sources_hash) + addon_digests = release_digests(addon, ADDON_ASSETS) if release_status(addon) == "published" else None + if "nodejs" in packages and addon_digests is None: + sys.exit(f"the add-on release of sources {sources_hash} is not published with every asset") + types = read_json(f"{TYPES_DIR}/package.json") + types_name = types["name"] + if "nodejs" in packages and not npm_published(types_name, types["version"]): + sys.exit(f"{types_name} {types['version']} is not published") + if "python" in packages: + core_commit = recorded_commit(releases, core_key(branch), core) + if changed_since(core_commit, PYTHON_TYPES_SOURCES): + sys.exit(f"the Python types differ from those of {libs_tag}") + nodejs = read_json(f"{NODEJS_DIR}/package.json") + python_version, python_pins = parse_python_version(read_text(PYTHON_VERSION_FILE)) result = plan( State( libs_tag=libs_tag, - libs_digests=release_digests(github_release(libs_tag), LIBS_ASSETS), - addon_tag=addon, - addon_digests=release_digests(github_release(addon), ADDON_ASSETS), + libs_digests=release_digests(tag_release(libs, libs_tag), LIBS_ASSETS), + addon_tag=addon["tag_name"] if addon else "", + addon_digests=addon_digests, nodejs_pins=parse_release(read_text(RELEASE_FILE)), python_pins=python_pins, - types=npm_package(TYPES_DIR, releases), + types_dependency=f"^{types['version']}", + nodejs_types_dependency=nodejs["dependencies"][types_name], nodejs=npm_package(NODEJS_DIR, releases), python=package_state( pypi_key(), @@ -542,24 +784,35 @@ def prepare(args: argparse.Namespace) -> None: releases, lambda: pypi_published(python_version), ), - types_version=args.types_version, + packages=packages, nodejs_version=args.nodejs_version, python_version=args.python_version, ) ) - (ROOT / RELEASE_FILE).write_text(render_release(result.nodejs_pins)) - (ROOT / PYTHON_VERSION_FILE).write_text(render_python_version(result.python_version, result.python_pins)) - types = read_json(f"{TYPES_DIR}/package.json") - write_json(f"{TYPES_DIR}/package.json", {**types, "version": result.types_version}) - nodejs = read_json(f"{NODEJS_DIR}/package.json") - dependencies = nodejs["dependencies"] - if result.types_dependency: - dependencies = {**dependencies, "@simplex-chat/types": result.types_dependency} - write_json(f"{NODEJS_DIR}/package.json", {**nodejs, "version": result.nodejs_version, "dependencies": dependencies}) - summary = result.title + "\n\n" + "".join(f"- {note}\n" for note in result.notes) - if args.summary: - Path(args.summary).write_text(summary) - print(summary, end="") + selected_pins = [ + pins for name, pins in [("nodejs", result.nodejs_pins), ("python", result.python_pins)] if name in packages + ] + if problems := [problem for pins in selected_pins for problem in published_pin_problems(pins)]: + sys.exit("\n".join(problems)) + assets = { + (tag, asset, pins.sha256[asset]) + for pins in selected_pins + for tag in pinned_tags(pins) + for asset in release_assets(tag) + } + with tempfile.TemporaryDirectory() as directory: + for tag, asset, sha256 in sorted(assets): + download(tag, asset, directory, sha256) + print(f"checked {asset} of {tag}", file=sys.stderr) + if "nodejs" in packages: + (ROOT / RELEASE_FILE).write_text(render_release(result.nodejs_pins)) + dependencies = nodejs["dependencies"] + if result.types_dependency: + dependencies = {**dependencies, types_name: result.types_dependency} + write_json(f"{NODEJS_DIR}/package.json", {**nodejs, "version": result.nodejs_version, "dependencies": dependencies}) + if "python" in packages: + (ROOT / PYTHON_VERSION_FILE).write_text(render_python_version(result.python_version, result.python_pins)) + print(result.title + "\n\n" + "".join(f"- {note}\n" for note in result.notes), end="") def publish_plan() -> None: @@ -579,7 +832,7 @@ def publish_plan() -> None: def record(args: argparse.Namespace) -> None: versions = [ - ("core", args.core), + (core_key(args.branch), args.core), (npm_key(TYPES_DIR), args.types), (npm_key(NODEJS_DIR), args.nodejs), (pypi_key(), args.python), @@ -617,35 +870,118 @@ def test_names() -> None: ) -def github_bool(value: str) -> bool: - """GitHub expressions pass booleans to the shell as "true", "false" or "".""" - return value == "true" +def print_release_status(tag: str) -> None: + print(f"status={release_status(tag_release(libs_releases(), tag))}") + + +def print_addon_release() -> None: + sources_hash = current_addon_hash() + release = addon_release(libs_releases(), sources_hash) + print(f"notes={shlex.quote(addon_notes(sources_hash))}") + print(f"status={release_status(release)}") + print(f"tag={release['tag_name'] if release else ''}") + + +def publish_release(tag: str) -> None: + release = tag_release(libs_releases(), tag) + if release is None: + sys.exit(f"{tag}: no release") + if not release["draft"]: + print(f"{tag} is published") + return + if missing := missing_assets(release): + sys.exit(f"{tag}: missing {', '.join(missing)}") + libs_api(f"releases/{release['id']}", "PATCH", {"draft": False}) + print(f"{tag} published") + + +def check_pins() -> None: + _, python_pins = parse_python_version(read_text(PYTHON_VERSION_FILE)) + problems = published_pin_problems(parse_release(read_text(RELEASE_FILE))) + published_pin_problems(python_pins) + if problems: + sys.exit("\n".join(problems)) + print("the pins match the published releases") + + +def download_asset(tag: str, asset: str, directory: str) -> None: + digests = release_digests(github_release(tag), [asset]) + if digests is None: + sys.exit(f"{asset} of {tag} is not published with a digest") + download(tag, asset, directory, digests[asset]) + + +def download_pinned(asset: str, directory: str) -> None: + pins = parse_release(read_text(RELEASE_FILE)) + tag = next((tag for tag in pinned_tags(pins) if asset in release_assets(tag)), None) + if tag is None or asset not in pins.sha256: + sys.exit(f"{asset} is not pinned in {RELEASE_FILE}") + download(tag, asset, directory, pins.sha256[asset]) def main(argv: list[str]) -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("addon-tag") + branch_help = "the branch of the core versions, the checked out branch by default" + core_release_parser = commands.add_parser("core-release") + core_release_parser.add_argument("version", help="the new core version") + core_release_parser.add_argument("--branch", help=branch_help) + core_release_parser.add_argument("--types-version", help="this types version instead of a bump when needed") + claim_parser = commands.add_parser("claim-core") + claim_parser.add_argument("version", help="the core version of simplex-chat.cabal") + claim_parser.add_argument("commit", help="the simplex-chat commit to release it from") + claim_parser.add_argument("branch", help="the branch of the commit") + status_parser = commands.add_parser("release-status") + status_parser.add_argument("tag") + commands.add_parser("addon-release") + latest_libs_parser = commands.add_parser("latest-libs") + latest_libs_parser.add_argument("--branch", help=branch_help) + publish_release_parser = commands.add_parser("publish-release") + publish_release_parser.add_argument("tag") + commands.add_parser("check-pins") + download_parser = commands.add_parser("download") + download_parser.add_argument("tag") + download_parser.add_argument("asset") + download_parser.add_argument("directory") + download_pinned_parser = commands.add_parser("download-pinned") + download_pinned_parser.add_argument("asset") + download_pinned_parser.add_argument("directory") changes_parser = commands.add_parser("changes") changes_parser.add_argument("base", help="the commit to compare with") build_parser = commands.add_parser("build-plan") - build_parser.add_argument("--event", required=True, help="github.event_name") - build_parser.add_argument("--base", default="", help="the base commit of a pull request") - build_parser.add_argument("--release-missing", type=github_bool, default=False) - build_parser.add_argument("--libs-release", default="") + build_parser.add_argument("--base", required=True, help="the base commit of the pull request") prepare_parser = commands.add_parser("prepare") - for package in ["nodejs", "types", "python"]: + prepare_parser.add_argument("packages", nargs="*", choices=["nodejs", "python"], help="all when none is listed") + prepare_parser.add_argument("--branch", help=branch_help) + for package in ["nodejs", "python"]: prepare_parser.add_argument(f"--{package}-version", help="this version instead of a bump when needed") - prepare_parser.add_argument("--summary", help="file for the PR title and body") commands.add_parser("publish-plan") record_parser = commands.add_parser("record") record_parser.add_argument("--commit", required=True, help="the simplex-chat commit released or published from") + record_parser.add_argument("--branch", default="master", help="the branch of the core version") for name in ["core", "types", "nodejs", "python"]: record_parser.add_argument(f"--{name}", default="", help=f"{name} version, recorded when not empty") commands.add_parser("test-names") args = parser.parse_args(argv) - if args.command == "addon-tag": - print(current_addon_tag()) + if args.command == "core-release": + core_release(args.version, args.branch or current_branch(), args.types_version) + elif args.command == "claim-core": + claim_core(args.version, args.commit, args.branch) + elif args.command == "release-status": + print_release_status(args.tag) + elif args.command == "addon-release": + print_addon_release() + elif args.command == "latest-libs": + releases, _ = read_releases() + versions = recorded_versions(releases, core_key(args.branch or current_branch())) + print(f"tag={latest_libs(libs_releases(), versions) or ''}") + elif args.command == "publish-release": + publish_release(args.tag) + elif args.command == "check-pins": + check_pins() + elif args.command == "download": + download_asset(args.tag, args.asset, args.directory) + elif args.command == "download-pinned": + download_pinned(args.asset, args.directory) elif args.command == "changes": changes(args.base) elif args.command == "build-plan": diff --git a/scripts/ci/test_packages_release.py b/scripts/ci/test_packages_release.py index 1838525c74..994e678690 100644 --- a/scripts/ci/test_packages_release.py +++ b/scripts/ci/test_packages_release.py @@ -1,7 +1,10 @@ +import argparse import base64 import contextlib import email.message +import hashlib import io +import json import subprocess import tomllib import urllib.error @@ -14,26 +17,41 @@ from unittest import mock import packages_release from packages_release import ( ADDON_ASSETS, + CABAL_FILE, NODEJS_DIR, + PYTHON_DIR, PYTHON_VERSION_FILE, + RELEASE_FILE, TYPES_DIR, + TYPES_README, ADDON_PLATFORMS, BuildPlan, LIBS_ASSETS, Package, Pins, State, - addon_tag, + addon_hash, + addon_notes, + addon_release, build_matrix, build_plan, bump_npm_version, bump_python_version, changed_since, add_releases, + claim_core, + core_release, + download, + highest_recorded, + latest_libs, + missing_assets, package_state, + pin_problems, + prepare, publish_plan, record_releases, recorded_commit, + release_status, render_releases, dist_tag, npm_published, @@ -45,6 +63,8 @@ from packages_release import ( release_digests, render_python_version, render_release, + tag_release, + types_problem, ) @@ -60,6 +80,10 @@ def github_release(assets: list[str], n: int) -> dict: return {"assets": [{"name": name, "digest": f"sha256:{sha(n)}"} for name in assets]} +def listed_release(tag: str, assets: list[str], n: int, draft: bool = False, body: str | None = None) -> dict: + return {"id": n, "tag_name": tag, "draft": draft, "body": body, **github_release(assets, n)} + + def state(**overrides) -> State: current = State( libs_tag="core-1.0.0.1", @@ -68,7 +92,8 @@ def state(**overrides) -> State: addon_digests=digests(ADDON_ASSETS, 2), nodejs_pins=Pins("core-1.0.0.1", {**digests(LIBS_ASSETS, 1), **digests(ADDON_ASSETS, 2)}, "nodejs-addon-aaa"), python_pins=Pins("core-1.0.0.1", digests(LIBS_ASSETS, 1)), - types=Package("0.12.1", changed=False, published=True), + types_dependency="^0.12.1", + nodejs_types_dependency="^0.12.1", nodejs=Package("7.1.0-beta.6.1", changed=False, published=True), python=Package("7.1.0b6", changed=False, published=True), ) @@ -101,15 +126,15 @@ class VersionTest(unittest.TestCase): self.assertEqual(registry_name("simplex-chat", None), "simplex-chat") -class AddonTagTest(unittest.TestCase): +class AddonHashTest(unittest.TestCase): def test_depends_on_sources_and_node_addon_api(self): files = {"a.cc": b"1", "b.h": b"2"} - tag = addon_tag(files, "^8.5.0") - self.assertRegex(tag, r"^nodejs-addon-[0-9a-f]{12}$") - self.assertEqual(addon_tag(dict(reversed(files.items())), "^8.5.0"), tag) - self.assertNotEqual(addon_tag({**files, "b.h": b"3"}, "^8.5.0"), tag) - self.assertNotEqual(addon_tag({"a.cc": b"1", "c.h": b"2"}, "^8.5.0"), tag) - self.assertNotEqual(addon_tag(files, "^8.6.0"), tag) + sources_hash = addon_hash(files, "^8.5.0") + self.assertRegex(sources_hash, r"^[0-9a-f]{12}$") + self.assertEqual(addon_hash(dict(reversed(files.items())), "^8.5.0"), sources_hash) + self.assertNotEqual(addon_hash({**files, "b.h": b"3"}, "^8.5.0"), sources_hash) + self.assertNotEqual(addon_hash({"a.cc": b"1", "c.h": b"2"}, "^8.5.0"), sources_hash) + self.assertNotEqual(addon_hash(files, "^8.6.0"), sources_hash) class PinFilesTest(unittest.TestCase): @@ -135,7 +160,7 @@ class ReleaseDigestsTest(unittest.TestCase): class PlanTest(unittest.TestCase): def test_nothing_changes_when_current(self): result = plan(state()) - self.assertEqual((result.nodejs_changed, result.python_changed, result.types_bumped), (False, False, False)) + self.assertEqual((result.nodejs_changed, result.python_changed, result.types_dependency), (False, False, None)) self.assertEqual(result.notes, []) def test_new_libs_release_updates_both_libraries(self): @@ -170,17 +195,17 @@ class PlanTest(unittest.TestCase): plan(state(python=Package("7.1.0b6", True, True))).title, "packages: release python 7.1.0b6.post1" ) - def test_types_bump_updates_the_nodejs_dependency(self): - result = plan(state(types=Package("0.12.1", True, True))) + def test_new_types_version_updates_the_nodejs_dependency(self): + result = plan(state(types_dependency="^0.12.2")) self.assertEqual(result.types_dependency, "^0.12.2") - self.assertEqual(result.title, "packages: release types 0.12.2, nodejs 7.1.0-beta.6.2") + self.assertEqual(result.title, "packages: release nodejs 7.1.0-beta.6.2") def test_all_changes_combine_into_one_release(self): result = plan( state( **NEW_LIBS, **NEW_ADDON, - types=Package("0.12.1", True, True), + types_dependency="^0.12.2", nodejs=Package("7.1.0-beta.6.1", True, True), python=Package("7.1.0b6", True, True), ) @@ -188,7 +213,16 @@ class PlanTest(unittest.TestCase): self.assertEqual( (result.nodejs_pins.libs_release, result.nodejs_pins.addon_release), ("core-1.0.0.2", "nodejs-addon-bbb") ) - self.assertEqual(result.title, "packages: release types 0.12.2, nodejs 7.1.0-beta.6.2, python 7.1.0b6.post1") + self.assertEqual(result.title, "packages: release nodejs 7.1.0-beta.6.2, python 7.1.0b6.post1") + + def test_unlisted_package_keeps_its_pins_and_version(self): + result = plan(state(**NEW_LIBS, types_dependency="^0.12.2", packages=frozenset({"python"}))) + self.assertEqual(result.nodejs_pins, state().nodejs_pins) + self.assertEqual( + (result.nodejs_version, result.nodejs_changed, result.types_dependency), ("7.1.0-beta.6.1", False, None) + ) + self.assertEqual(result.python_pins.libs_release, "core-1.0.0.2") + self.assertEqual(result.title, "packages: release python 7.1.0b6.post1") def test_unpublished_versions_are_not_bumped_again(self): result = plan( @@ -199,29 +233,18 @@ class PlanTest(unittest.TestCase): self.assertEqual(result.title, "packages: release nodejs 7.1.0-beta.6.2, python 7.1.0b6.post1") def test_requested_versions_win(self): - result = plan(state(nodejs_version="7.2.0", types_version="0.13.0", python_version="7.2.0")) - self.assertEqual(result.types_dependency, "^0.13.0") - self.assertEqual(result.title, "packages: release types 0.13.0, nodejs 7.2.0, python 7.2.0") + result = plan(state(nodejs_version="7.2.0", python_version="7.2.0")) + self.assertEqual(result.title, "packages: release nodejs 7.2.0, python 7.2.0") class BuildPlanTest(unittest.TestCase): - def plan(self, event="push", release_missing=False, libs_release="", core=False, addon=False): - return build_plan(event, release_missing, libs_release, core, addon) - - def test_push_builds_a_missing_addon_release_and_tests_it(self): - self.assertEqual(self.plan(release_missing=True), BuildPlan(ADDON_PLATFORMS, test=True)) - self.assertEqual(self.plan(), BuildPlan([], test=True)) - def test_pull_request_tests_with_the_pinned_libs(self): - self.assertEqual(self.plan("pull_request"), BuildPlan(ADDON_PLATFORMS, test=True)) - self.assertEqual(self.plan("pull_request", addon=True), BuildPlan(ADDON_PLATFORMS, test=True)) + self.assertEqual(build_plan(core_changed=False, addon_changed=False), BuildPlan(ADDON_PLATFORMS, test=True)) + self.assertEqual(build_plan(core_changed=False, addon_changed=True), BuildPlan(ADDON_PLATFORMS, test=True)) def test_core_change_leaves_tests_to_build_yml(self): - self.assertEqual(self.plan("pull_request", core=True), BuildPlan([], test=False)) - self.assertEqual(self.plan("pull_request", core=True, addon=True), BuildPlan(ADDON_PLATFORMS, test=False)) - - def test_new_library_release_is_tested_on_every_platform(self): - self.assertEqual(self.plan(libs_release="core-1.0.0.2"), BuildPlan(ADDON_PLATFORMS, test=True)) + self.assertEqual(build_plan(core_changed=True, addon_changed=False), BuildPlan([], test=False)) + self.assertEqual(build_plan(core_changed=True, addon_changed=True), BuildPlan(ADDON_PLATFORMS, test=False)) def test_matrix_names_the_runner_of_each_platform(self): self.assertEqual( @@ -297,6 +320,12 @@ class ReleasesFileTest(unittest.TestCase): self.assertIsNone(recorded_commit(self.RELEASES, "npm/simplex-chat", "7.1.0-beta.6.4")) self.assertIsNone(recorded_commit({}, "pypi/simplex-chat", "7.1.0b6")) + def test_highest_recorded_version_is_compared_numerically(self): + releases = {"core": {"7.1.0.9": "a", "7.1.0.11": "b"}, "npm": {"@simplex-chat/types": {"0.9.0": "c"}}} + self.assertEqual(highest_recorded(releases, "core"), "7.1.0.11") + self.assertEqual(highest_recorded(releases, "npm/@simplex-chat/types"), "0.9.0") + self.assertIsNone(highest_recorded(releases, "npm/simplex-chat")) + def test_adds_entries_and_keeps_recorded_commits(self): updated = add_releases(self.RELEASES, [("npm/simplex-chat", "7.1.0-beta.6.4"), ("core", "7.1.0.11")], "x") self.assertEqual(updated["npm"]["simplex-chat"], {"7.1.0-beta.6.3": "n1", "7.1.0-beta.6.4": "x"}) @@ -355,13 +384,10 @@ class RecordReleasesTest(unittest.TestCase): self.assertEqual(record_releases([("core", "1")], "other"), "libs0") -class PackageStateTest(unittest.TestCase): +class GitRepoTest(unittest.TestCase): def setUp(self): self.repo = Path(tempfile.mkdtemp()) self.git("init", "-q") - self.write("pkg/version.txt", 'version = "1"\n') - self.write("pkg/src.txt", "a") - self.commit("set version") def git(self, *args): subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=self.repo, check=True) @@ -374,6 +400,19 @@ class PackageStateTest(unittest.TestCase): self.git("add", "-A") self.git("commit", "-q", "-m", message) + def head(self): + return subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=self.repo, capture_output=True, text=True + ).stdout.strip() + + +class PackageStateTest(GitRepoTest): + def setUp(self): + super().setUp() + self.write("pkg/version.txt", 'version = "1"\n') + self.write("pkg/src.txt", "a") + self.commit("set version") + def state(self, releases=None, registry_published=False): with mock.patch.object(packages_release, "ROOT", self.repo): return package_state( @@ -383,11 +422,6 @@ class PackageStateTest(unittest.TestCase): def changed(self): return self.state().changed - def head(self): - return subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=self.repo, capture_output=True, text=True - ).stdout.strip() - def test_recorded_version_is_published_and_compared_with_its_commit(self): self.write("pkg/src.txt", "b") self.commit("change published with version 1") @@ -467,5 +501,303 @@ class PackageStateTest(unittest.TestCase): self.assertFalse(self.changed()) +class ReleasesTest(unittest.TestCase): + def test_latest_libs_is_the_highest_complete_published_version(self): + releases = [ + listed_release("core-7.1.0.9", LIBS_ASSETS, 1), + listed_release("core-7.1.0.11", LIBS_ASSETS, 2), + listed_release("core-7.1.0.12", LIBS_ASSETS[1:], 3), + listed_release("core-7.1.0.13", LIBS_ASSETS, 4, draft=True), + listed_release("core-test-7.1.0.14", LIBS_ASSETS, 5), + listed_release("nodejs-addon-1111111", ADDON_ASSETS, 6), + ] + versions = {"7.1.0.9", "7.1.0.11", "7.1.0.12", "7.1.0.13", "test-7.1.0.14"} + self.assertEqual(latest_libs(releases, versions), "core-7.1.0.11") + self.assertEqual(latest_libs(releases, {"7.1.0.9"}), "core-7.1.0.9") + self.assertIsNone(latest_libs(releases, {"7.0.0.12"})) + + def test_published_release_is_preferred_to_a_draft(self): + draft = listed_release("core-7.1.0.12", LIBS_ASSETS, 1, draft=True) + published = listed_release("core-7.1.0.12", LIBS_ASSETS, 2) + self.assertEqual(tag_release([draft, published], "core-7.1.0.12"), published) + self.assertEqual(tag_release([draft], "core-7.1.0.12"), draft) + statuses = [ + release_status(tag_release(releases, "core-7.1.0.12")) for releases in ([draft, published], [draft], []) + ] + self.assertEqual(statuses, ["published", "draft", "absent"]) + + def test_addon_release_is_found_by_its_notes(self): + other = listed_release("nodejs-addon-1111111", ADDON_ASSETS, 1, body=addon_notes("bbb")) + draft = listed_release("nodejs-addon-2222222", ADDON_ASSETS, 2, draft=True, body=addon_notes("aaa")) + published = listed_release("nodejs-addon-3333333", ADDON_ASSETS, 3, body=addon_notes("aaa")) + self.assertEqual(addon_release([other, draft, published], "aaa"), published) + self.assertEqual(addon_release([other, draft], "aaa"), draft) + self.assertIsNone(addon_release([other], "aaa")) + + def test_missing_assets_of_a_draft(self): + self.assertEqual(missing_assets(listed_release("core-7.1.0.12", LIBS_ASSETS[1:], 1)), LIBS_ASSETS[:1]) + self.assertEqual(missing_assets(listed_release("nodejs-addon-1111111", ADDON_ASSETS, 1)), []) + + def test_pin_problems_list_mismatches_and_unpublished_releases(self): + pins = Pins("core-7.1.0.11", {**digests(LIBS_ASSETS, 1), **digests(ADDON_ASSETS, 2)}, "nodejs-addon-1111111") + published = { + "core-7.1.0.11": github_release(LIBS_ASSETS, 1), + "nodejs-addon-1111111": github_release(ADDON_ASSETS, 2), + } + self.assertEqual(pin_problems(pins, published), []) + self.assertEqual( + len(pin_problems(pins, {**published, "core-7.1.0.11": github_release(LIBS_ASSETS, 3)})), len(LIBS_ASSETS) + ) + self.assertEqual( + pin_problems(pins, {**published, "nodejs-addon-1111111": None}), ["nodejs-addon-1111111 is not published"] + ) + + +class PublishReleaseTest(unittest.TestCase): + def publish(self, releases): + api = mock.Mock() + with ( + mock.patch.object(packages_release, "libs_releases", return_value=releases), + mock.patch.object(packages_release, "libs_api", api), + contextlib.redirect_stdout(io.StringIO()), + ): + packages_release.publish_release("core-7.1.0.12") + return api + + def test_complete_draft_is_published(self): + api = self.publish([listed_release("core-7.1.0.12", LIBS_ASSETS, 7, draft=True)]) + api.assert_called_once_with("releases/7", "PATCH", {"draft": False}) + + def test_draft_with_missing_assets_stays_a_draft(self): + with self.assertRaises(SystemExit): + self.publish([listed_release("core-7.1.0.12", LIBS_ASSETS[1:], 7, draft=True)]) + + def test_published_release_is_left_unchanged(self): + self.publish([listed_release("core-7.1.0.12", LIBS_ASSETS, 7)]).assert_not_called() + + +class ClaimCoreTest(unittest.TestCase): + def claim(self, recorded, after_record=None, problem=None, version="7.1.0.12", branch="master"): + reads = iter([(recorded, "blob"), (after_record or recorded, "blob")]) + self.record = mock.Mock(return_value="libs1") + output = io.StringIO() + with ( + mock.patch.object(packages_release, "read_releases", lambda: next(reads)), + mock.patch.object(packages_release, "record_releases", self.record), + mock.patch.object(packages_release, "types_problem", return_value=problem), + contextlib.redirect_stdout(output), + ): + claim_core(version, "sx1", branch) + return output.getvalue().splitlines() + + def test_lower_version_fails(self): + with self.assertRaises(SystemExit): + self.claim({"core": {"master": {"7.1.0.13": "sx0"}}}) + + def test_version_recorded_for_this_commit_resumes(self): + self.assertEqual(self.claim({"core": {"master": {"7.1.0.12": "sx1"}}}), ["claim=resume"]) + + def test_version_recorded_for_another_commit_is_skipped(self): + self.assertEqual(self.claim({"core": {"master": {"7.1.0.11": "sx0", "7.1.0.12": "sx0"}}}), ["claim=skip"]) + + def test_version_recorded_on_another_branch_is_skipped(self): + recorded = {"core": {"master": {"7.1.0.11": "sx0"}, "stable": {"7.1.0.12": "sx2"}}} + self.assertEqual(self.claim(recorded), ["claim=skip"]) + + def test_failed_types_check_fails_before_recording(self): + with self.assertRaises(SystemExit): + self.claim({"core": {"master": {"7.1.0.11": "sx0"}}}, problem="types changed") + self.record.assert_not_called() + + def test_unrecorded_version_is_claimed(self): + claimed = {"core": {"master": {"7.1.0.11": "sx0", "7.1.0.12": "sx1"}}} + self.assertEqual(self.claim({"core": {"master": {"7.1.0.11": "sx0"}}}, claimed), ["claim=new", "commit=libs1"]) + self.record.assert_called_once_with([("core/master", "7.1.0.12")], "sx1") + + def test_stable_versions_are_compared_with_stable_only(self): + recorded = {"core": {"master": {"7.1.0.11": "sx0"}, "stable": {"7.0.0.12": "sx3"}}} + claimed = {"core": {**recorded["core"], "stable": {"7.0.0.12": "sx3", "7.0.0.13": "sx1"}}} + self.assertEqual( + self.claim(recorded, claimed, version="7.0.0.13", branch="stable"), ["claim=new", "commit=libs1"] + ) + self.record.assert_called_once_with([("core/stable", "7.0.0.13")], "sx1") + with self.assertRaises(SystemExit): + self.claim(recorded, version="7.0.0.11", branch="stable") + + def test_concurrent_claim_of_another_commit_is_skipped(self): + claimed = {"core": {"master": {"7.1.0.11": "sx0", "7.1.0.12": "sx2"}}} + self.assertEqual(self.claim({"core": {"master": {"7.1.0.11": "sx0"}}}, claimed), ["claim=skip"]) + + +class TypesProblemTest(unittest.TestCase): + def problem(self, types, changed=True, published=False, releases=None): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = Path(directory.name) + (root / TYPES_DIR).mkdir(parents=True) + (root / TYPES_DIR / "package.json").write_text(json.dumps({"name": "@simplex-chat/types", **types})) + with ( + mock.patch.object(packages_release, "ROOT", root), + mock.patch.object(packages_release, "types_changed", return_value=changed), + mock.patch.object(packages_release, "npm_published", return_value=published), + ): + return types_problem("7.1.0.12", releases or {}, "master") + + def test_unchanged_types_pass(self): + self.assertIsNone(self.problem({"version": "0.12.1", "simplexChatCore": "7.1.0.11"}, changed=False)) + + def test_changed_types_with_a_new_version_for_the_core_pass(self): + self.assertIsNone(self.problem({"version": "0.12.2", "simplexChatCore": "7.1.0.12"})) + + def test_changed_types_with_a_published_version_fail(self): + self.assertIsNotNone(self.problem({"version": "0.12.1", "simplexChatCore": "7.1.0.12"}, published=True)) + recorded = {"npm": {"@simplex-chat/types": {"0.12.1": "c"}}} + self.assertIsNotNone(self.problem({"version": "0.12.1", "simplexChatCore": "7.1.0.12"}, releases=recorded)) + + def test_changed_types_for_another_core_fail(self): + self.assertIsNotNone(self.problem({"version": "0.12.2", "simplexChatCore": "7.1.0.11"})) + + +class CoreReleaseTest(GitRepoTest): + def setUp(self): + super().setUp() + self.write(CABAL_FILE, "name: simplex-chat\nversion: 7.1.0.11\n") + types = {"name": "@simplex-chat/types", "version": "0.12.1", "simplexChatCore": "7.1.0.11"} + self.write(f"{TYPES_DIR}/package.json", json.dumps(types, indent=2) + "\n") + self.write(TYPES_README, "# Types\n\nSimpleX Chat core: 7.1.0.11\n") + self.write(f"{TYPES_DIR}/src/types.ts", "a") + self.commit("types 0.12.1") + self.releases = {"core": {"master": {"7.1.0.11": self.head()}}} + + def release(self, version, types_version=None): + with ( + mock.patch.object(packages_release, "ROOT", self.repo), + mock.patch.object(packages_release, "read_releases", return_value=(self.releases, "blob")), + contextlib.redirect_stdout(io.StringIO()), + ): + core_release(version, "master", types_version) + + def read(self, file): + return (self.repo / file).read_text() + + def types(self): + return json.loads(self.read(f"{TYPES_DIR}/package.json")) + + def test_changed_types_get_a_new_version_for_the_core(self): + self.write(f"{TYPES_DIR}/src/types.ts", "b") + self.commit("types change") + self.release("7.1.0.12") + self.assertIn("version: 7.1.0.12\n", self.read(CABAL_FILE)) + self.assertEqual((self.types()["version"], self.types()["simplexChatCore"]), ("0.12.2", "7.1.0.12")) + self.assertIn("SimpleX Chat core: 7.1.0.12\n", self.read(TYPES_README)) + + def test_unchanged_types_keep_their_version(self): + self.release("7.1.0.12") + self.assertIn("version: 7.1.0.12\n", self.read(CABAL_FILE)) + self.assertEqual((self.types()["version"], self.types()["simplexChatCore"]), ("0.12.1", "7.1.0.11")) + + def test_requested_types_version_wins(self): + self.release("7.1.0.12", "7.1.0-beta.6.1") + self.assertEqual((self.types()["version"], self.types()["simplexChatCore"]), ("7.1.0-beta.6.1", "7.1.0.12")) + + def test_types_version_is_bumped_from_the_previous_core_release(self): + self.write(f"{TYPES_DIR}/package.json", json.dumps({**self.types(), "version": "0.12.5"})) + self.write(f"{TYPES_DIR}/src/types.ts", "b") + self.commit("types change and a manual version") + self.release("7.1.0.12") + self.assertEqual(self.types()["version"], "0.12.2") + + def test_version_must_be_higher_than_the_recorded_one(self): + with self.assertRaises(SystemExit): + self.release("7.1.0.11") + self.assertIn("version: 7.1.0.11\n", self.read(CABAL_FILE)) + + +class PrepareTest(GitRepoTest): + def setUp(self): + super().setUp() + nodejs = { + "name": "simplex-chat", + "version": "7.1.0-beta.6.1", + "dependencies": {"@simplex-chat/types": "^0.12.1"}, + "devDependencies": {"node-addon-api": "^8.5.0"}, + } + self.write(CABAL_FILE, "version: 1.0.0.2\n") + self.write(f"{NODEJS_DIR}/binding.gyp", "{}") + self.write(f"{NODEJS_DIR}/cpp/simplex.cc", "add-on") + self.write(f"{NODEJS_DIR}/package.json", json.dumps(nodejs)) + self.write(RELEASE_FILE, render_release(state().nodejs_pins)) + self.write(f"{TYPES_DIR}/package.json", json.dumps({"name": "@simplex-chat/types", "version": "0.12.1"})) + self.write(PYTHON_VERSION_FILE, render_python_version("7.1.0b6", state().python_pins)) + self.write(f"{PYTHON_DIR}/src/simplex_chat/types/_types.py", "a") + self.write(f"{PYTHON_DIR}/pyproject.toml", 'name = "simplex-chat"\n') + self.commit("packages") + with mock.patch.object(packages_release, "ROOT", self.repo): + sources_hash = packages_release.current_addon_hash() + self.libs = [ + listed_release("core-1.0.0.2", LIBS_ASSETS, 3), + listed_release("nodejs-addon-1111111", ADDON_ASSETS, 4, body=addon_notes(sources_hash)), + ] + self.releases = { + "core": {"master": {"1.0.0.2": self.head()}}, + "npm": {"simplex-chat": {"7.1.0-beta.6.1": self.head()}}, + "pypi": {"simplex-chat": {"7.1.0b6": self.head()}}, + } + + def prepare(self, *packages): + published = {release["tag_name"]: release for release in self.libs} + download = mock.Mock() + with ( + mock.patch.object(packages_release, "ROOT", self.repo), + mock.patch.object(packages_release, "read_releases", return_value=(self.releases, "blob")), + mock.patch.object(packages_release, "libs_releases", return_value=self.libs), + mock.patch.object(packages_release, "github_release", published.get), + mock.patch.object(packages_release, "npm_published", return_value=True), + mock.patch.object(packages_release, "pypi_published", return_value=True), + mock.patch.object(packages_release, "download", download), + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + ): + prepare( + argparse.Namespace(packages=list(packages), branch="master", nodejs_version=None, python_version=None) + ) + return download + + def test_listed_package_is_released_alone(self): + download = self.prepare("nodejs") + pins = parse_release((self.repo / RELEASE_FILE).read_text()) + self.assertEqual((pins.libs_release, pins.addon_release), ("core-1.0.0.2", "nodejs-addon-1111111")) + self.assertEqual(json.loads((self.repo / NODEJS_DIR / "package.json").read_text())["version"], "7.1.0-beta.6.2") + python = parse_python_version((self.repo / PYTHON_VERSION_FILE).read_text()) + self.assertEqual(python, ("7.1.0b6", state().python_pins)) + self.assertEqual(download.call_count, len(LIBS_ASSETS) + len(ADDON_ASSETS)) + + def test_python_types_newer_than_the_pinned_core_stop_the_release(self): + self.write(f"{PYTHON_DIR}/src/simplex_chat/types/_types.py", "b") + self.commit("python types change") + with self.assertRaises(SystemExit): + self.prepare("python") + self.assertEqual(parse_python_version((self.repo / PYTHON_VERSION_FILE).read_text())[0], "7.1.0b6") + + +class DownloadTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.directory = directory.name + + def download(self, sha256): + with mock.patch.object(packages_release.urllib.request, "urlopen", return_value=io.BytesIO(b"data")): + return download("core-1.0.0.1", "a.zip", self.directory, sha256) + + def test_matching_digest_keeps_the_file(self): + self.assertEqual(self.download(hashlib.sha256(b"data").hexdigest()).read_bytes(), b"data") + + def test_mismatching_digest_removes_the_file(self): + with self.assertRaises(SystemExit): + self.download(sha(1)) + self.assertFalse((Path(self.directory) / "a.zip").exists()) + + if __name__ == "__main__": unittest.main() diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 16ab1fcb59..747e32db2e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 7.1.0.11 +version: 7.1.0.12 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 924f2b96449b2fc68532c4e95dc8ab550dfbb302 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 19 Oct 2026 08:32:09 +0100 Subject: [PATCH 08/10] api: fix python code generation (#7764) * api: fix python code generation * fix python types --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- bots/src/API/Docs/Generate/Python.hs | 45 +++++++++---------- .../src/simplex_chat/types/_types.py | 7 +-- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/bots/src/API/Docs/Generate/Python.hs b/bots/src/API/Docs/Generate/Python.hs index 64aa1d1062..8d60bced77 100644 --- a/bots/src/API/Docs/Generate/Python.hs +++ b/bots/src/API/Docs/Generate/Python.hs @@ -60,10 +60,7 @@ commandsCodeText = <> respAliasLine where constrName = T.pack $ fstToUpper tag - classDef = - ("class " <> constrName <> "(TypedDict):\n") - <> bodyOrPass (fieldsCodePy " " "T." params) - <> "\n" + classDef = typedDictCode constrName (fieldsCodePy "T." params) <> "\n" cmdStringFunc = ("\ndef " <> constrName <> "_cmd_string(self: " <> constrName <> ") -> str:\n") <> " return " <> pySelfSyntaxText "T." (fstToUpper tag, params) syntax <> "\n" @@ -143,9 +140,7 @@ typesCodeText = "FileProtocol" -> map toUpper m _ -> m typeDefCode = case typeDef of - ATDRecord fields -> - ("\nclass " <> name' <> "(TypedDict):\n") - <> bodyOrPass (fieldsCodePy " " "" fields) + ATDRecord fields -> "\n" <> typedDictCode name' (fieldsCodePy "" fields) ATDEnum cs -> "\n" <> name' <> " = Literal[" <> T.intercalate ", " (map (\m -> "\"" <> T.pack (enumValue m) <> "\"") $ L.toList cs) @@ -231,9 +226,7 @@ unionTypeCodePy memberName typesNamespace name cs = constrTypeRef (ATUnionMember tag _) = memberName name tag tagLiterals = T.intercalate ", " $ map (\(ATUnionMember tag _) -> "\"" <> T.pack tag <> "\"") $ L.toList cs memberClass (ATUnionMember tag fields) = - ("\nclass " <> memberName name tag <> "(TypedDict):\n") - <> (" type: Literal[\"" <> T.pack tag <> "\"]\n") - <> fieldsCodePy " " typesNamespace fields + "\n" <> typedDictCode (memberName name tag) (("type", "Literal[\"" <> T.pack tag <> "\"]", "") : fieldsCodePy typesNamespace fields) -- | Render the right-hand side of a union alias: either inline (one line) or -- multi-line wrapped in parentheses with `|` separators between alternatives. @@ -248,21 +241,27 @@ unionAliasRhs lhs constr cs (h : t) -> h : map ("| " <>) t cs' = map constr cs --- | Emit a body of `pass` if there are no fields, otherwise the rendered --- fields as-is. -bodyOrPass :: Text -> Text -bodyOrPass body - | T.null body = " pass\n" - | otherwise = body +typedDictCode :: Text -> [(Text, Text, Text)] -> Text +typedDictCode name fields + | any (\(field, _, _) -> field `elem` pyKeywords) fields = + (name <> " = TypedDict(\"" <> name <> "\", {\n") + <> foldMap (\(field, t, comment) -> " \"" <> field <> "\": " <> t <> "," <> comment <> "\n") fields + <> "})\n" + | null fields = "class " <> name <> "(TypedDict):\n pass\n" + | otherwise = + ("class " <> name <> "(TypedDict):\n") + <> foldMap (\(field, t, comment) -> " " <> field <> ": " <> t <> comment <> "\n") fields --- | Render record fields for a TypedDict body. Each field becomes --- `: [ # ]`. Optional fields wrap the type in --- `NotRequired[...]`. -fieldsCodePy :: Text -> Text -> [APIRecordField] -> Text -fieldsCodePy indent namespace = foldMap render +pyKeywords :: [Text] +pyKeywords = + ["False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield"] + +-- | Render record fields for a TypedDict as name, type and comment. +-- Optional fields wrap the type in `NotRequired[...]`. +fieldsCodePy :: Text -> [APIRecordField] -> [(Text, Text, Text)] +fieldsCodePy namespace = map render where - render (APIRecordField name t) = - indent <> T.pack name <> ": " <> wrapOptional t (typeText t) <> typeComment t <> "\n" + render (APIRecordField name t) = (T.pack name, wrapOptional t (typeText t), typeComment t) wrapOptional t inner = case t of ATOptional _ -> "NotRequired[" <> inner <> "]" _ -> inner diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 373cf58d43..5457f9c154 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -3071,9 +3071,10 @@ class SecurityCode(TypedDict): securityCode: str verifiedAt: str # ISO-8601 timestamp -class SegmentRange(TypedDict): - from: int # int - to: int # int +SegmentRange = TypedDict("SegmentRange", { + "from": int, # int + "to": int, # int +}) class SimplePreference(TypedDict): allow: "FeatureAllowed" From bdc4edc524de9fea751e6b19e459804347ec3fde Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:07:52 +0400 Subject: [PATCH 09/10] nix: build armv7a from master with ghc 8.10.7 (#7763) --- docs/CONTRIBUTING.md | 17 +- flake.lock | 918 +++++++++++++++++- flake.nix | 134 +-- scripts/android/build-android.sh | 11 +- scripts/desktop/build-lib-linux.sh | 2 +- scripts/desktop/build-lib-mac.sh | 2 +- scripts/desktop/build-lib-windows.sh | 2 +- .../simplex-chat-reproduce-builds-android.sh | 7 +- 8 files changed, 974 insertions(+), 119 deletions(-) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 3ecfa17409..7dbf4510e9 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -57,11 +57,7 @@ You will have to add `/opt/homebrew/opt/openssl@3.0/bin` to your PATH in order t - `stable` - stable release of the apps, can be used for updates to the previous stable release (GHC 9.6.3). -- `stable-android` - used to build stable Android core library with Nix (GHC 8.10.7) - only for Android armv7a. - -- `master` - branch for beta version releases (compatible with both GHC 9.6.3 and 8.10.7). - -- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7) - only for Android armv7a. +- `master` - branch for beta version releases (compatible with both GHC 9.6.3 and 8.10.7). Nix builds Android armv7a core library with GHC 8.10.7 and all other core libraries with GHC 9.6.3. **In simplexmq repo** @@ -71,18 +67,13 @@ You will have to add `/opt/homebrew/opt/openssl@3.0/bin` to your PATH in order t 1. Make PRs to `master` branch _only_ for both simplex-chat and simplexmq repos. -2. To build core libraries for Android, iOS and Windows: -- merge `master` branch to `master-android` branch. -- push to GitHub. +2. To build core libraries for Android, iOS and Windows, push `master` branch to GitHub. -3. All libraries should be built from `master` branch, Android armv7a - from `master-android` branch. +3. All libraries should be built from `master` branch. 4. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. -5. After the public release to App Store and Play Store, merge: -- `master` to `stable` -- `master` to `master-android` (and compile/update code) -- `master-android` to `stable-android` +5. After the public release to App Store and Play Store, merge `master` to `stable`. 6. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. diff --git a/flake.lock b/flake.lock index eac7357cd8..2b1d187945 100644 --- a/flake.lock +++ b/flake.lock @@ -16,6 +16,37 @@ "type": "github" } }, + "HTTP_2": { + "flake": false, + "locked": { + "lastModified": 1451647621, + "narHash": "sha256-oHIyw3x0iKBexEo49YeUDV1k74ZtyYKGR2gNJXXRxts=", + "owner": "phadej", + "repo": "HTTP", + "rev": "9bc0996d412fef1787449d841277ef663ad9a915", + "type": "github" + }, + "original": { + "owner": "phadej", + "repo": "HTTP", + "type": "github" + } + }, + "blank": { + "locked": { + "lastModified": 1625557891, + "narHash": "sha256-O8/MWsPBGhhyPoPLHZAuoZiiHo9q6FLlEeIDEXuj6T4=", + "owner": "divnix", + "repo": "blank", + "rev": "5a5d2684073d9f563072ed07c871d577a6c614a8", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "blank", + "type": "github" + } + }, "cabal-32": { "flake": false, "locked": { @@ -33,6 +64,23 @@ "type": "github" } }, + "cabal-32_2": { + "flake": false, + "locked": { + "lastModified": 1603716527, + "narHash": "sha256-X0TFfdD4KZpwl0Zr6x+PLxUt/VyKQfX7ylXHdmZIL+w=", + "owner": "haskell", + "repo": "cabal", + "rev": "48bf10787e27364730dd37a42b603cee8d6af7ee", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.2", + "repo": "cabal", + "type": "github" + } + }, "cabal-34": { "flake": false, "locked": { @@ -50,6 +98,23 @@ "type": "github" } }, + "cabal-34_2": { + "flake": false, + "locked": { + "lastModified": 1645834128, + "narHash": "sha256-wG3d+dOt14z8+ydz4SL7pwGfe7SiimxcD/LOuPCV6xM=", + "owner": "haskell", + "repo": "cabal", + "rev": "5ff598c67f53f7c4f48e31d722ba37172230c462", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.4", + "repo": "cabal", + "type": "github" + } + }, "cabal-36": { "flake": false, "locked": { @@ -67,6 +132,23 @@ "type": "github" } }, + "cabal-36_2": { + "flake": false, + "locked": { + "lastModified": 1669081697, + "narHash": "sha256-I5or+V7LZvMxfbYgZATU4awzkicBwwok4mVoje+sGmU=", + "owner": "haskell", + "repo": "cabal", + "rev": "8fd619e33d34924a94e691c5fea2c42f0fc7f144", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.6", + "repo": "cabal", + "type": "github" + } + }, "cardano-shell": { "flake": false, "locked": { @@ -83,6 +165,80 @@ "type": "github" } }, + "cardano-shell_2": { + "flake": false, + "locked": { + "lastModified": 1608537748, + "narHash": "sha256-PulY1GfiMgKVnBci3ex4ptk2UNYMXqGjJOxcPy2KYT4=", + "owner": "input-output-hk", + "repo": "cardano-shell", + "rev": "9392c75087cb9a3d453998f4230930dea3a95725", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "cardano-shell", + "type": "github" + } + }, + "devshell": { + "inputs": { + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "std", + "flake-utils" + ], + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1663445644, + "narHash": "sha256-+xVlcK60x7VY1vRJbNUEAHi17ZuoQxAIH4S4iUFUGBA=", + "owner": "numtide", + "repo": "devshell", + "rev": "e3dc3e21594fe07bdb24bdf1c8657acaa4cb8f66", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "dmerge": { + "inputs": { + "nixlib": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ], + "yants": [ + "haskellNix-ghc8107", + "tullia", + "std", + "yants" + ] + }, + "locked": { + "lastModified": 1659548052, + "narHash": "sha256-fzI2gp1skGA8mQo/FBFrUAtY0GQkAIAaV/V127TJPyY=", + "owner": "divnix", + "repo": "data-merge", + "rev": "d160d18ce7b1a45b88344aa3f13ed1163954b497", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "data-merge", + "type": "github" + } + }, "flake-compat": { "flake": false, "locked": { @@ -100,6 +256,39 @@ "type": "github" } }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1672831974, + "narHash": "sha256-z9k3MfslLjWQfnjBtEtJZdq3H7kyi2kQtUThfTgdRk0=", + "owner": "input-output-hk", + "repo": "flake-compat", + "rev": "45f2638735f8cdc40fe302742b79f248d23eb368", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "ref": "hkm/gitlab-fix", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-compat_3": { + "flake": false, + "locked": { + "lastModified": 1650374568, + "narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "b4a34015c698c7793d592d66adbab377907a2be8", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, "flake-parts": { "inputs": { "nixpkgs-lib": "nixpkgs-lib" @@ -136,6 +325,51 @@ "type": "github" } }, + "flake-utils_2": { + "locked": { + "lastModified": 1667395993, + "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_3": { + "locked": { + "lastModified": 1653893745, + "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_4": { + "locked": { + "lastModified": 1659877975, + "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "ghc-8.6.5-iohk": { "flake": false, "locked": { @@ -153,6 +387,23 @@ "type": "github" } }, + "ghc-8.6.5-iohk_2": { + "flake": false, + "locked": { + "lastModified": 1600920045, + "narHash": "sha256-DO6kxJz248djebZLpSzTGD6s8WRpNI9BTwUeOf5RwY8=", + "owner": "input-output-hk", + "repo": "ghc", + "rev": "95713a6ecce4551240da7c96b6176f980af75cae", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "ref": "release/8.6.5-iohk", + "repo": "ghc", + "type": "github" + } + }, "ghc98X": { "flake": false, "locked": { @@ -190,6 +441,25 @@ "url": "https://gitlab.haskell.org/ghc/ghc" } }, + "gomod2nix": { + "inputs": { + "nixpkgs": "nixpkgs_3", + "utils": "utils" + }, + "locked": { + "lastModified": 1655245309, + "narHash": "sha256-d/YPoQ/vFn1+GTmSdvbSBSTOai61FONxB4+Lt6w/IVI=", + "owner": "tweag", + "repo": "gomod2nix", + "rev": "40d32f82fc60d66402eb0972e6e368aeab3faf58", + "type": "github" + }, + "original": { + "owner": "tweag", + "repo": "gomod2nix", + "type": "github" + } + }, "hackage": { "flake": false, "locked": { @@ -206,6 +476,23 @@ "type": "github" } }, + "hackage-ghc8107": { + "flake": false, + "locked": { + "lastModified": 1702340598, + "narHash": "sha256-CC0HI+6iKPtH+8r/ZfcpW5v/OYvL7zMwpr0xfkXV1zU=", + "owner": "input-output-hk", + "repo": "hackage.nix", + "rev": "24617c569995e38bf3b83b48eec6628a50fdb4fb", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "hackage.nix", + "rev": "24617c569995e38bf3b83b48eec6628a50fdb4fb", + "type": "github" + } + }, "haskellNix": { "inputs": { "HTTP": "HTTP", @@ -260,6 +547,50 @@ "type": "github" } }, + "haskellNix-ghc8107": { + "inputs": { + "HTTP": "HTTP_2", + "cabal-32": "cabal-32_2", + "cabal-34": "cabal-34_2", + "cabal-36": "cabal-36_2", + "cardano-shell": "cardano-shell_2", + "flake-compat": "flake-compat_2", + "flake-utils": "flake-utils_2", + "ghc-8.6.5-iohk": "ghc-8.6.5-iohk_2", + "hackage": [ + "hackage-ghc8107" + ], + "hpc-coveralls": "hpc-coveralls_2", + "hydra": "hydra_2", + "iserv-proxy": "iserv-proxy_2", + "nixpkgs": [ + "nixpkgs-ghc8107" + ], + "nixpkgs-2003": "nixpkgs-2003_2", + "nixpkgs-2105": "nixpkgs-2105_2", + "nixpkgs-2111": "nixpkgs-2111_2", + "nixpkgs-2205": "nixpkgs-2205_2", + "nixpkgs-2211": "nixpkgs-2211_2", + "nixpkgs-unstable": "nixpkgs-unstable_2", + "old-ghc-nix": "old-ghc-nix_2", + "stackage": "stackage_2", + "tullia": "tullia" + }, + "locked": { + "lastModified": 1677975916, + "narHash": "sha256-dbe8lEEPyfzjdRwpePClv7J9p9lQg7BwbBqAMCw4RLw=", + "owner": "input-output-hk", + "repo": "haskell.nix", + "rev": "ab5efd87ce3fd8ade38a01d97693d29a4f1ae7e4", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "haskell.nix", + "rev": "ab5efd87ce3fd8ade38a01d97693d29a4f1ae7e4", + "type": "github" + } + }, "hls-1.10": { "flake": false, "locked": { @@ -395,6 +726,22 @@ "type": "github" } }, + "hpc-coveralls_2": { + "flake": false, + "locked": { + "lastModified": 1607498076, + "narHash": "sha256-8uqsEtivphgZWYeUo5RDUhp6bO9j2vaaProQxHBltQk=", + "owner": "sevanspowell", + "repo": "hpc-coveralls", + "rev": "14df0f7d229f4cd2e79f8eabb1a740097fdfa430", + "type": "github" + }, + "original": { + "owner": "sevanspowell", + "repo": "hpc-coveralls", + "type": "github" + } + }, "hydra": { "inputs": { "nix": "nix", @@ -418,6 +765,52 @@ "type": "indirect" } }, + "hydra_2": { + "inputs": { + "nix": "nix_2", + "nixpkgs": [ + "haskellNix-ghc8107", + "hydra", + "nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1671755331, + "narHash": "sha256-hXsgJj0Cy0ZiCiYdW2OdBz5WmFyOMKuw4zyxKpgUKm4=", + "owner": "NixOS", + "repo": "hydra", + "rev": "f48f00ee6d5727ae3e488cbf9ce157460853fea8", + "type": "github" + }, + "original": { + "id": "hydra", + "type": "indirect" + } + }, + "incl": { + "inputs": { + "nixlib": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1669263024, + "narHash": "sha256-E/+23NKtxAqYG/0ydYgxlgarKnxmDbg6rCMWnOBqn9Q=", + "owner": "divnix", + "repo": "incl", + "rev": "ce7bebaee048e4cd7ebdb4cee7885e00c4e2abca", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "incl", + "type": "github" + } + }, "iserv-proxy": { "flake": false, "locked": { @@ -435,6 +828,23 @@ "url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git" } }, + "iserv-proxy_2": { + "flake": false, + "locked": { + "lastModified": 1670983692, + "narHash": "sha256-avLo34JnI9HNyOuauK5R69usJm+GfW3MlyGlYxZhTgY=", + "ref": "hkm/remote-iserv", + "rev": "50d0abb3317ac439a4e7495b185a64af9b7b9300", + "revCount": 10, + "type": "git", + "url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git" + }, + "original": { + "ref": "hkm/remote-iserv", + "type": "git", + "url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git" + } + }, "lowdown-src": { "flake": false, "locked": { @@ -451,10 +861,26 @@ "type": "github" } }, + "lowdown-src_2": { + "flake": false, + "locked": { + "lastModified": 1633514407, + "narHash": "sha256-Dw32tiMjdK9t3ETl5fzGrutQTzh2rufgZV4A/BbxuD4=", + "owner": "kristapsdz", + "repo": "lowdown", + "rev": "d2c2b44ff6c27b936ec27358a2653caaef8f73b8", + "type": "github" + }, + "original": { + "owner": "kristapsdz", + "repo": "lowdown", + "type": "github" + } + }, "mac2ios": { "inputs": { "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_6" }, "locked": { "lastModified": 1699767871, @@ -470,6 +896,35 @@ "type": "github" } }, + "n2c": { + "inputs": { + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "std", + "flake-utils" + ], + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1665039323, + "narHash": "sha256-SAh3ZjFGsaCI8FRzXQyp56qcGdAqgKEfJWPCQ0Sr7tQ=", + "owner": "nlewo", + "repo": "nix2container", + "rev": "b008fe329ffb59b67bf9e7b08ede6ee792f2741a", + "type": "github" + }, + "original": { + "owner": "nlewo", + "repo": "nix2container", + "type": "github" + } + }, "nix": { "inputs": { "lowdown-src": "lowdown-src", @@ -491,6 +946,116 @@ "type": "github" } }, + "nix-nomad": { + "inputs": { + "flake-compat": "flake-compat_3", + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "nix2container", + "flake-utils" + ], + "gomod2nix": "gomod2nix", + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "nixpkgs" + ], + "nixpkgs-lib": [ + "haskellNix-ghc8107", + "tullia", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1658277770, + "narHash": "sha256-T/PgG3wUn8Z2rnzfxf2VqlR1CBjInPE0l1yVzXxPnt0=", + "owner": "tristanpemble", + "repo": "nix-nomad", + "rev": "054adcbdd0a836ae1c20951b67ed549131fd2d70", + "type": "github" + }, + "original": { + "owner": "tristanpemble", + "repo": "nix-nomad", + "type": "github" + } + }, + "nix2container": { + "inputs": { + "flake-utils": "flake-utils_3", + "nixpkgs": "nixpkgs_4" + }, + "locked": { + "lastModified": 1658567952, + "narHash": "sha256-XZ4ETYAMU7XcpEeAFP3NOl9yDXNuZAen/aIJ84G+VgA=", + "owner": "nlewo", + "repo": "nix2container", + "rev": "60bb43d405991c1378baf15a40b5811a53e32ffa", + "type": "github" + }, + "original": { + "owner": "nlewo", + "repo": "nix2container", + "type": "github" + } + }, + "nix_2": { + "inputs": { + "lowdown-src": "lowdown-src_2", + "nixpkgs": "nixpkgs_2", + "nixpkgs-regression": "nixpkgs-regression_2" + }, + "locked": { + "lastModified": 1661606874, + "narHash": "sha256-9+rpYzI+SmxJn+EbYxjGv68Ucp22bdFUSy/4LkHkkDQ=", + "owner": "NixOS", + "repo": "nix", + "rev": "11e45768b34fdafdcf019ddbd337afa16127ff0f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "2.11.0", + "repo": "nix", + "type": "github" + } + }, + "nixago": { + "inputs": { + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "std", + "flake-utils" + ], + "nixago-exts": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1661824785, + "narHash": "sha256-/PnwdWoO/JugJZHtDUioQp3uRiWeXHUdgvoyNbXesz8=", + "owner": "nix-community", + "repo": "nixago", + "rev": "8c1f9e5f1578d4b2ea989f618588d62a335083c3", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixago", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1657693803, @@ -523,6 +1088,22 @@ "type": "github" } }, + "nixpkgs-2003_2": { + "locked": { + "lastModified": 1620055814, + "narHash": "sha256-8LEHoYSJiL901bTMVatq+rf8y7QtWuZhwwpKE2fyaRY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1db42b7fe3878f3f5f7a4f2dc210772fd080e205", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-20.03-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2105": { "locked": { "lastModified": 1659914493, @@ -539,6 +1120,22 @@ "type": "github" } }, + "nixpkgs-2105_2": { + "locked": { + "lastModified": 1659914493, + "narHash": "sha256-lkA5X3VNMKirvA+SUzvEhfA7XquWLci+CGi505YFAIs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "022caabb5f2265ad4006c1fa5b1ebe69fb0c3faf", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-21.05-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2111": { "locked": { "lastModified": 1659446231, @@ -555,6 +1152,22 @@ "type": "github" } }, + "nixpkgs-2111_2": { + "locked": { + "lastModified": 1659446231, + "narHash": "sha256-hekabNdTdgR/iLsgce5TGWmfIDZ86qjPhxDg/8TlzhE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "eabc38219184cc3e04a974fe31857d8e0eac098d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-21.11-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2205": { "locked": { "lastModified": 1685573264, @@ -571,6 +1184,22 @@ "type": "github" } }, + "nixpkgs-2205_2": { + "locked": { + "lastModified": 1672580127, + "narHash": "sha256-3lW3xZslREhJogoOkjeZtlBtvFMyxHku7I/9IVehhT8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0874168639713f547c05947c76124f78441ea46c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-22.05-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2211": { "locked": { "lastModified": 1688392541, @@ -587,6 +1216,22 @@ "type": "github" } }, + "nixpkgs-2211_2": { + "locked": { + "lastModified": 1675730325, + "narHash": "sha256-uNvD7fzO5hNlltNQUAFBPlcEjNG5Gkbhl/ROiX+GZU4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b7ce17b1ebf600a72178f6302c77b6382d09323f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-22.11-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2305": { "locked": { "lastModified": 1705033721, @@ -619,6 +1264,22 @@ "type": "github" } }, + "nixpkgs-ghc8107": { + "locked": { + "lastModified": 1676726892, + "narHash": "sha256-M7OYVR6dKmzmlebIjybFf3l18S2uur8lMyWWnHQooLY=", + "owner": "angerman", + "repo": "nixpkgs", + "rev": "729469087592bdea58b360de59dadf6d58714c42", + "type": "github" + }, + "original": { + "owner": "angerman", + "repo": "nixpkgs", + "rev": "729469087592bdea58b360de59dadf6d58714c42", + "type": "github" + } + }, "nixpkgs-lib": { "locked": { "dir": "lib", @@ -653,6 +1314,22 @@ "type": "github" } }, + "nixpkgs-regression_2": { + "locked": { + "lastModified": 1643052045, + "narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + } + }, "nixpkgs-unstable": { "locked": { "lastModified": 1694822471, @@ -669,7 +1346,86 @@ "type": "github" } }, + "nixpkgs-unstable_2": { + "locked": { + "lastModified": 1675758091, + "narHash": "sha256-7gFSQbSVAFUHtGCNHPF7mPc5CcqDk9M2+inlVPZSneg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "747927516efcb5e31ba03b7ff32f61f6d47e7d87", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_2": { + "locked": { + "lastModified": 1657693803, + "narHash": "sha256-G++2CJ9u0E7NNTAi9n5G8TdDmGJXcIjkJ3NF8cetQB8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "365e1b3a859281cf11b94f87231adeabbdd878a2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-22.05-small", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1653581809, + "narHash": "sha256-Uvka0V5MTGbeOfWte25+tfRL3moECDh1VwokWSZUdoY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "83658b28fe638a170a19b8933aa008b30640fbd1", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { + "locked": { + "lastModified": 1654807842, + "narHash": "sha256-ADymZpr6LuTEBXcy6RtFHcUZdjKTBRTMYwu19WOx17E=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fc909087cc3386955f21b4665731dbdaceefb1d8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_5": { + "locked": { + "lastModified": 1665087388, + "narHash": "sha256-FZFPuW9NWHJteATOf79rZfwfRn5fE0wi9kRzvGfDHPA=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "95fda953f6db2e9496d2682c4fc7b82f959878f7", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_6": { "locked": { "lastModified": 1698434055, "narHash": "sha256-Phxi5mUKSoL7A0IYUiYtkI9e8NcGaaV5PJEaJApU1Ko=", @@ -685,6 +1441,21 @@ "type": "github" } }, + "nosys": { + "locked": { + "lastModified": 1667881534, + "narHash": "sha256-FhwJ15uPLRsvaxtt/bNuqE/ykMpNAPF0upozFKhTtXM=", + "owner": "divnix", + "repo": "nosys", + "rev": "2d0d5207f6a230e9d0f660903f8db9807b54814f", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "nosys", + "type": "github" + } + }, "old-ghc-nix": { "flake": false, "locked": { @@ -702,16 +1473,36 @@ "type": "github" } }, + "old-ghc-nix_2": { + "flake": false, + "locked": { + "lastModified": 1631092763, + "narHash": "sha256-sIKgO+z7tj4lw3u6oBZxqIhDrzSkvpHtv0Kki+lh9Fg=", + "owner": "angerman", + "repo": "old-ghc-nix", + "rev": "af48a7a7353e418119b6dfe3cd1463a657f342b8", + "type": "github" + }, + "original": { + "owner": "angerman", + "ref": "master", + "repo": "old-ghc-nix", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", "hackage": "hackage", + "hackage-ghc8107": "hackage-ghc8107", "haskellNix": "haskellNix", + "haskellNix-ghc8107": "haskellNix-ghc8107", "mac2ios": "mac2ios", "nixpkgs": [ "haskellNix", "nixpkgs-2305" - ] + ], + "nixpkgs-ghc8107": "nixpkgs-ghc8107" } }, "stackage": { @@ -730,6 +1521,67 @@ "type": "github" } }, + "stackage_2": { + "flake": false, + "locked": { + "lastModified": 1677888571, + "narHash": "sha256-YkhRNOaN6QVagZo1cfykYV8KqkI8/q6r2F5+jypOma4=", + "owner": "input-output-hk", + "repo": "stackage.nix", + "rev": "cb50e6fabdfb2d7e655059039012ad0623f06a27", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "stackage.nix", + "type": "github" + } + }, + "std": { + "inputs": { + "arion": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "blank": "blank", + "devshell": "devshell", + "dmerge": "dmerge", + "flake-utils": "flake-utils_4", + "incl": "incl", + "makes": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "microvm": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "n2c": "n2c", + "nixago": "nixago", + "nixpkgs": "nixpkgs_5", + "nosys": "nosys", + "yants": "yants" + }, + "locked": { + "lastModified": 1674526466, + "narHash": "sha256-tMTaS0bqLx6VJ+K+ZT6xqsXNpzvSXJTmogkraBGzymg=", + "owner": "divnix", + "repo": "std", + "rev": "516387e3d8d059b50e742a2ff1909ed3c8f82826", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "std", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, @@ -744,6 +1596,68 @@ "repo": "default", "type": "github" } + }, + "tullia": { + "inputs": { + "nix-nomad": "nix-nomad", + "nix2container": "nix2container", + "nixpkgs": [ + "haskellNix-ghc8107", + "nixpkgs" + ], + "std": "std" + }, + "locked": { + "lastModified": 1675695930, + "narHash": "sha256-B7rEZ/DBUMlK1AcJ9ajnAPPxqXY6zW2SBX+51bZV0Ac=", + "owner": "input-output-hk", + "repo": "tullia", + "rev": "621365f2c725608f381b3ad5b57afef389fd4c31", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "tullia", + "type": "github" + } + }, + "utils": { + "locked": { + "lastModified": 1653893745, + "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "yants": { + "inputs": { + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1667096281, + "narHash": "sha256-wRRec6ze0gJHmGn6m57/zhz/Kdvp9HS4Nl5fkQ+uIuA=", + "owner": "divnix", + "repo": "yants", + "rev": "d18f356ec25cb94dc9c275870c3a7927a10f8c3c", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "yants", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 84f84c34ec..26ece60cbf 100644 --- a/flake.nix +++ b/flake.nix @@ -8,8 +8,17 @@ flake = false; }; inputs.haskellNix.inputs.hackage.follows = "hackage"; + # armv7a android is built with GHC 8.10.7; its toolchain is pinned by revision so updates of the inputs above don't move it. + inputs.haskellNix-ghc8107.url = "github:input-output-hk/haskell.nix/ab5efd87ce3fd8ade38a01d97693d29a4f1ae7e4"; + inputs.nixpkgs-ghc8107.url = "github:angerman/nixpkgs/729469087592bdea58b360de59dadf6d58714c42"; + inputs.hackage-ghc8107 = { + url = "github:input-output-hk/hackage.nix/24617c569995e38bf3b83b48eec6628a50fdb4fb"; + flake = false; + }; + inputs.haskellNix-ghc8107.inputs.nixpkgs.follows = "nixpkgs-ghc8107"; + inputs.haskellNix-ghc8107.inputs.hackage.follows = "hackage-ghc8107"; inputs.flake-utils.url = "github:numtide/flake-utils"; - outputs = { self, haskellNix, nixpkgs, flake-utils, mac2ios, ... }: + outputs = { self, haskellNix, haskellNix-ghc8107, nixpkgs, flake-utils, mac2ios, ... }: let systems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; in flake-utils.lib.eachSystem systems (system: # this android26 overlay makes the pkgsCross.{aarch64-android,armv7a-android-prebuilt} to set stdVer to 26 (Android 8). @@ -29,8 +38,9 @@ }; in # `appendOverlays` with a singleton is identical to `extend`. let pkgs = haskellNix.legacyPackages.${system}.appendOverlays [android26]; in - let drv' = { extra-modules, pkgs', ... }: pkgs'.haskell-nix.project { - compiler-nix-name = "ghc963"; + let pkgs8107 = haskellNix-ghc8107.legacyPackages.${system}.appendOverlays [android26]; in + let drv' = { extra-modules, pkgs', compiler-nix-name ? "ghc963", ... }: pkgs'.haskell-nix.project { + inherit compiler-nix-name; index-state = "2023-12-12T00:00:00Z"; # We need this, to specify we want the cabal project. # If the stack.yaml was dropped, this would not be necessary. @@ -126,16 +136,20 @@ "x86_64-linux" = let androidPkgs = pkgs.pkgsCross.aarch64-android; - android32Pkgs = pkgs.pkgsCross.armv7a-android-prebuilt; + android32Pkgs = pkgs8107.pkgsCross.armv7a-android-prebuilt; # For some reason building libiconv with nixpgks android setup produces # LANGINFO_CODESET to be found, which is not compatible with android sdk 23; # so we'll patch up iconv to not include that. - androidIconv = (androidPkgs.libiconv.override { enableStatic = true; }).overrideAttrs (old: { + mkAndroidIconv = androidPkgs: (androidPkgs.libiconv.override { enableStatic = true; }).overrideAttrs (old: { postConfigure = '' echo "#undef HAVE_LANGINFO_CODESET" >> libcharset/config.h echo "#undef HAVE_LANGINFO_CODESET" >> lib/config.h ''; }); + androidIconv = mkAndroidIconv androidPkgs; + # Only interpolated in a commented-out line of armv7a postInstall; it must come from the + # GHC 8.10.7 nixpkgs to keep that derivation identical to the former master-android build. + androidIconv8107 = mkAndroidIconv pkgs8107.pkgsCross.aarch64-android; # Similarly to icovn, for reasons beyond my current knowledge, nixpkgs andorid # toolchain makes configure believe we have MEMFD_CREATE, which we don't in # sdk 23. @@ -275,37 +289,22 @@ # STATIC aarch64-linux "${pkgs.pkgsCross.aarch64-multiplatform-musl.hostPlatform.system}-static:exe:simplex-chat" = (drv pkgs.pkgsCross.aarch64-multiplatform-musl).simplex-chat.components.exes.simplex-chat; - "armv7a-android:lib:support" = (drv android32Pkgs).android-support.components.library.override (p: { - smallAddressSpace = true; - # we won't want -dyamic (see aarch64-android:lib:simplex-chat) - enableShared = false; - # we also do not want to have any dependencies listed (especially no rts!) - enableStatic = false; - - # This used to work with 8.10.7... - # setupBuildFlags = p.component.setupBuildFlags ++ map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; - # ... but now with 9.6+ - # we have to do the -shared thing by hand. - postBuild = '' - armv7a-unknown-linux-androideabi-ghc -shared -o libsupport.so \ - -optl-Wl,-u,setLineBuffering \ - -optl-Wl,-u,pipe_std_to_socket \ - dist/build/*.a - ''; - + "armv7a-android:lib:support" = (drv' { pkgs' = android32Pkgs; extra-modules = []; compiler-nix-name = "ghc8107"; }).android-support.components.library.override { + smallAddressSpace = true; enableShared = false; + setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; postInstall = '' mkdir -p $out/_pkg cp libsupport.so $out/_pkg - ${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsupport.so - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-armv7a-android-libsupport.zip *) + ${pkgs8107.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsupport.so + (cd $out/_pkg; ${pkgs8107.zip}/bin/zip -r -9 $out/pkg-armv7a-android-libsupport.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ > $out/nix-support/hydra-build-products ''; - }); + }; # The android-support package is at # https://github.com/simplex-chat/android-support "aarch64-android:lib:support" = (drv androidPkgs).android-support.components.library.override (p: { @@ -340,10 +339,10 @@ }); "armv7a-android:lib:simplex-chat" = (drv' { pkgs' = android32Pkgs; + compiler-nix-name = "ghc8107"; extra-modules = [{ - packages.text.flags.simdutf = false; packages.direct-sqlcipher.flags.openssl = true; - packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [ + packages.direct-sqlcipher.components.library.libs = pkgs8107.lib.mkForce [ (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) ]; packages.direct-sqlcipher.patches = [ @@ -351,70 +350,21 @@ ]; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; - packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + packages.simplexmq.components.library.libs = pkgs8107.lib.mkForce [ (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) ]; - # 32 bit patches - packages.basement.patches = [ - ./scripts/nix/basement-pr-573.patch - ]; - packages.memory.patches = [ - ./scripts/nix/memory-pr-99.patch - ]; }]; - }).simplex-chat.components.library.override (p: { - smallAddressSpace = true; - # we want -shared, but not -dyanmic, hence `enableShared = false`. - enableShared = false; - # we _do_ want rts, and other libs. Hence `enableStatic = true`. - enableStatic = true; + }).simplex-chat.components.library.override { + smallAddressSpace = true; enableShared = false; # for android we build a shared library, passing these arguments is a bit tricky, as # we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for # template haskell cross compilation. Thus we just pass them as linker options (-optl). - setupBuildFlags = p.component.setupBuildFlags - # flags to tell GHC we want to produce a -shared object, and we want to also link - # - the ffi library (ffi) - ++ map (x: "--ghc-option=${x}") [ - "-shared" "-o" "libsimplex.so" - "-threaded" - # "-debug" - "-optl-lffi" - "-j1" - ] - # This is fairly idiotic. LLD will strip out foreign exported - # symbols (a GHC bug? Codegen bug?). So we need to pass `-u ` - # to ensure they stay in the produced library. Having them - # _undefined_ and _lazy_ (lld will tell with -y that the - # symbol is lazy), makes them _defined_. m( - ++ map (sym: "--ghc-option=-optl-Wl,-u,${sym}") [ - "chat_close_store" - "chat_decrypt_file" - "chat_decrypt_media" - "chat_encrypt_file" - "chat_encrypt_media" - "chat_migrate_init" - "chat_migrate_init_queue" - "chat_parse_markdown" - "chat_parse_server" - "chat_parse_uri" - "chat_password_hash" - "chat_read_file" - "chat_recv_msg" - "chat_recv_msg_wait" - "chat_send_cmd" - "chat_send_cmd_retry" - "chat_send_remote_cmd" - "chat_send_remote_cmd_retry" - "chat_valid_name" - "chat_parse_badge_code" - "chat_json_length" - "chat_badge_keygen" - "chat_badge_issue" - "chat_write_file" - ]; + # 32-bit ARM only: processAgentMessageConn is too large for ARM's PC-relative constant-pool + # range at -O2; -O1 is appended after the cabal -O2 here, so it wins (aarch64 stays -O2). + setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi" "-j1" "-O1"]; postInstall = '' set -x - ${pkgs.tree}/bin/tree $out + ${pkgs8107.tree}/bin/tree $out mkdir -p $out/_pkg # copy over includes, we might want those, but maybe not. # cp -r $out/lib/*/*/include $out/_pkg/ @@ -425,7 +375,7 @@ # find ./dist -name "libHS*-ghc*.a" -exec cp {} $out/_pkg \; # find ${android32FFI}/lib -name "*.a" -exec cp {} $out/_pkg \; # find ${android32Pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; - # find ${androidIconv}/lib -name "*.a" -exec cp {} $out/_pkg \; + # find ${androidIconv8107}/lib -name "*.a" -exec cp {} $out/_pkg \; # find ${android32Pkgs.stdenv.cc.libc}/lib -name "*.a" -exec cp {} $out/_pkg \; echo ${android32Pkgs.openssl.override { enableKTLS = false; }} find ${(android32Pkgs.openssl.override { enableKTLS = false; }).out}/lib -name "*.so" -exec cp {} $out/_pkg \; @@ -433,22 +383,22 @@ # remove the .1 and other version suffixes from .so's. Androids linker # doesn't play nice with them. for lib in $out/_pkg/*.so; do - for dep in $(${pkgs.patchelf}/bin/patchelf --print-needed "$lib"); do + for dep in $(${pkgs8107.patchelf}/bin/patchelf --print-needed "$lib"); do if [[ "''${dep##*.so}" ]]; then echo "$lib : $dep -> ''${dep%%.so*}.so" chmod +w "$lib" - ${pkgs.patchelf}/bin/patchelf --replace-needed "$dep" "''${dep%%.so*}.so" "$lib" + ${pkgs8107.patchelf}/bin/patchelf --replace-needed "$dep" "''${dep%%.so*}.so" "$lib" fi done done for lib in $out/_pkg/*.so; do chmod +w "$lib" - ${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so "$lib" - [[ "$lib" != *libsimplex.so ]] && ${pkgs.patchelf}/bin/patchelf --set-soname "$(basename -a $lib)" "$lib" + ${pkgs8107.patchelf}/bin/patchelf --remove-needed libunwind.so "$lib" + [[ "$lib" != *libsimplex.so ]] && ${pkgs8107.patchelf}/bin/patchelf --set-soname "$(basename -a $lib)" "$lib" done - ${pkgs.tree}/bin/tree $out/_pkg + ${pkgs8107.tree}/bin/tree $out/_pkg # Strip from debug symbols find "$out/_pkg" -type f -name "*.so" -exec ${android32Pkgs.stdenv.cc.targetPrefix}strip --strip-unneeded {} + @@ -458,13 +408,13 @@ find "$out/_pkg" -type d -exec chmod 755 {} + find "$out/_pkg" -exec touch -h -d '@1764547200' {} + - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 -X $out/pkg-armv7a-android-libsimplex.zip *) + (cd $out/_pkg; ${pkgs8107.zip}/bin/zip -r -9 -X $out/pkg-armv7a-android-libsimplex.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ > $out/nix-support/hydra-build-products ''; - }); + }; "aarch64-android:lib:simplex-chat" = (drv' { pkgs' = androidPkgs; extra-modules = [{ diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 267db9f243..7542ed3205 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -105,14 +105,9 @@ build() { sed -i.bak "s/android.version_code=.*/android.version_code=${vercode}/" "$folder/apps/multiplatform/gradle.properties" for arch in $arches; do - if [ "$arch" = "armv7a" ]; then - android_simplex_lib="${folder}#hydraJobs.${arch}-android:lib:simplex-chat.x86_64-linux" - android_support_lib="${folder}#hydraJobs.${arch}-android:lib:support.x86_64-linux" - else - android_simplex_lib="${folder}#hydraJobs.x86_64-linux.${arch}-android:lib:simplex-chat" - android_support_lib="${folder}#hydraJobs.x86_64-linux.${arch}-android:lib:support" - fi - + android_simplex_lib="${folder}#hydraJobs.x86_64-linux.${arch}-android:lib:simplex-chat" + android_support_lib="${folder}#hydraJobs.x86_64-linux.${arch}-android:lib:support" + android_simplex_lib_output="${PWD}/result/pkg-${arch}-android-libsimplex.zip" android_support_lib_output="${PWD}/result/pkg-${arch}-android-libsupport.zip" diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh index ea5fc9b760..3006f5aad0 100755 --- a/scripts/desktop/build-lib-linux.sh +++ b/scripts/desktop/build-lib-linux.sh @@ -24,7 +24,7 @@ BUILD_DIR=$DIST_DIR/build/$ARCH-$OS/ghc-${GHC_VERSION}/simplex-chat-* exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) ) for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done -for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done +for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in flake.nix. Add \"$elem\" to aarch64-android:lib:simplex-chat; exit 1; fi ; done #rm -rf $BUILD_DIR if [[ "$DATABASE_BACKEND" == "postgres" ]]; then diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 5563086c29..e69aacd422 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -22,7 +22,7 @@ BUILD_DIR=dist-newstyle/build/$ARCH-*/ghc-*/simplex-chat-* exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) ) for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done -for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done +for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in flake.nix. Add \"$elem\" to aarch64-android:lib:simplex-chat; exit 1; fi ; done rm -rf $BUILD_DIR diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index 15e021adf7..ad62b2d209 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -19,7 +19,7 @@ BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-*/simplex-chat-* exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/Chat/Mobile.hs | sed 's/foreign export ccall "chat_reopen_store"//' |grep "foreign export ccall" | cut -d '"' -f2) ) for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done -for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done +for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in flake.nix. Add \"$elem\" to aarch64-android:lib:simplex-chat; exit 1; fi ; done # IMPORTANT: in order to get a working build you should use x86_64 MinGW with make, cmake, gcc. # 100% working MinGW is https://github.com/brechtsanders/winlibs_mingw/releases/download/13.1.0-16.0.5-11.0.0-ucrt-r5/winlibs-x86_64-posix-seh-gcc-13.1.0-mingw-w64ucrt-11.0.0-r5.zip diff --git a/scripts/simplex-chat-reproduce-builds-android.sh b/scripts/simplex-chat-reproduce-builds-android.sh index 4bd7262d17..45bf2a6936 100755 --- a/scripts/simplex-chat-reproduce-builds-android.sh +++ b/scripts/simplex-chat-reproduce-builds-android.sh @@ -246,7 +246,12 @@ Continue?' for arch in $ARCHES; do case "$arch" in armv7a) - build_tag="${tag}-armv7a" + # Releases made before the master-android branch was merged have armv7a in a separate tag. + if git -C "$build_directory" rev-parse -q --verify "refs/tags/${tag}-armv7a" > /dev/null; then + build_tag="${tag}-armv7a" + else + build_tag="${tag}" + fi ;; aarch64) build_tag="${tag}" From 0eeac1bb920f460820acea4c43f4b909da5b7643 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:00:31 +0400 Subject: [PATCH 10/10] docs: normalize badge in-app checkout wireframe --- ...026-09-23-badge-in-app-checkout.excalidraw | 9588 +---------------- 1 file changed, 243 insertions(+), 9345 deletions(-) diff --git a/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw b/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw index 25caf53711..3e48751677 100644 --- a/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw +++ b/plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw @@ -3,9350 +3,248 @@ "version": 2, "source": "https://excalidraw-jetbrains-plugin", "elements": [ - { - "id": "hdr-bg", - "type": "rectangle", - "x": 0, - "y": 0, - "width": 2500, - "height": 96, - "strokeColor": "transparent", - "backgroundColor": "#1c1e26", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": null, - "groupIds": [ - "header" - ], - "seed": 1001, - "index": "a000", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "hdr-title", - "type": "text", - "x": 40, - "y": 24, - "width": 620, - "height": 28, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "header" - ], - "seed": 1002, - "text": "Buying a badge - hidden codes", - "fontSize": 22, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a001", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Buying a badge - hidden codes", - "lineHeight": 1.2727272727272727 - }, - { - "id": "hdr-sub", - "type": "text", - "x": 40, - "y": 58, - "width": 1500, - "height": 15, - "strokeColor": "#a0a6b0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "header" - ], - "seed": 1003, - "text": "the shipped code purchase, reused whole: the page still mints a code, the deep link carries it, and the buyer never sees it. solid = exists, dashed = does not", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a002", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "the shipped code purchase, reused whole: the page still mints a code, the deep link carries it, and the buyer never sees it. solid = exists, dashed = does not", - "lineHeight": 1.25 - }, - { - "id": "hdr-r1", - "type": "text", - "x": 2260, - "y": 26, - "width": 200, - "height": 15, - "strokeColor": "#a0a6b0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "header" - ], - "seed": 1004, - "text": "in-app checkout", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "right", - "verticalAlign": "top", - "autoResize": false, - "index": "a003", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "in-app checkout", - "lineHeight": 1.25 - }, - { - "id": "hdr-r2", - "type": "text", - "x": 2260, - "y": 48, - "width": 200, - "height": 14, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "header" - ], - "seed": 1005, - "text": "wireframes", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "right", - "verticalAlign": "top", - "autoResize": false, - "index": "a004", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "wireframes", - "lineHeight": 1.2727272727272727 - }, - { - "id": "l1-band", - "type": "rectangle", - "x": 20, - "y": 150, - "width": 2460, - "height": 850, - "strokeColor": "transparent", - "backgroundColor": "#eef5ff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "lane1" - ], - "seed": 1010, - "index": "a010", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "l1-head", - "type": "text", - "x": 60, - "y": 178, - "width": 900, - "height": 22, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane1" - ], - "seed": 1011, - "text": "Non-store builds - the browser does all of it", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a011", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Non-store builds - the browser does all of it", - "lineHeight": 1.2941176470588236 - }, - { - "id": "l1-sub", - "type": "text", - "x": 60, - "y": 206, - "width": 1300, - "height": 14, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane1" - ], - "seed": 1012, - "text": "no catalog API, no invoice API, no in-app checkout: the app opens the page, the page sells a code, the deep link brings it home", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a012", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "no catalog API, no invoice API, no in-app checkout: the app opens the page, the page sells a code, the deep link brings it home", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a1-panel", - "type": "rectangle", - "x": 60, - "y": 260, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1101, - "boundElements": [ - { - "id": "ar-a1a2", - "type": "arrow" - } - ], - "index": "a020", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a1-title", - "type": "text", - "x": 80, - "y": 306, - "width": 260, - "height": 24, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1102, - "text": "Support SimpleX", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a021", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Support SimpleX", - "lineHeight": 1.263157894736842 - }, - { - "id": "a1-body", - "type": "text", - "x": 76, - "y": 348, - "width": 268, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1103, - "text": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a022", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", - "lineHeight": 1.3 - }, - { - "id": "a1-info", - "type": "text", - "x": 80, - "y": 404, - "width": 260, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1104, - "text": "(i) How badges protect your privacy", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a023", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "(i) How badges protect your privacy", - "lineHeight": 1.3 - }, - { - "id": "a1-hero", - "type": "rectangle", - "x": 132, - "y": 448, - "width": 156, - "height": 156, - "strokeColor": "transparent", - "backgroundColor": "#e8eef7", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1105, - "index": "a024", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a1-herot", - "type": "text", - "x": 132, - "y": 519, - "width": 156, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1106, - "text": "phone-supporter", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a025", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "phone-supporter", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a1-btn", - "type": "rectangle", - "x": 90, - "y": 730, - "width": 240, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#0053d0", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1107, - "boundElements": [ - { - "id": "a1-btnt", - "type": "text" - } - ], - "index": "a026", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a1-btnt", - "type": "text", - "x": 100, - "y": 742, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1108, - "text": "Buy in browser", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a1-btn", - "index": "a027", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Buy in browser", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a1-sec", - "type": "text", - "x": 80, - "y": 786, - "width": 260, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1109, - "text": "Redeem code", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a028", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Redeem code", - "lineHeight": 1.3 - }, - { - "id": "a1-secn", - "type": "text", - "x": 80, - "y": 806, - "width": 260, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1112, - "text": "the manual path, for a code from elsewhere", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a029", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "the manual path, for a code from elsewhere", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a1-capt", - "type": "text", - "x": 60, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1110, - "text": "A1. BadgesSupportSimplexView", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a02a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A1. BadgesSupportSimplexView", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a1-capb", - "type": "text", - "x": 60, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A1", - "lane1" - ], - "seed": 1111, - "text": "Get your code becomes the primary button and loses the\nconfirm-in-browser alert - the tap opens the browser. Redeem\nbadge code drops to the text button. The info button stays as\nit is - the file's TODO would swap it for Why SimpleX is built\nonce purchase lands, and it should not.\nchooseLevelButton() is never called in this build.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a02b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Get your code becomes the primary button and loses the\nconfirm-in-browser alert - the tap opens the browser. Redeem\nbadge code drops to the text button. The info button stays as\nit is - the file's TODO would swap it for Why SimpleX is built\nonce purchase lands, and it should not.\nchooseLevelButton() is never called in this build.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "a2-panel", - "type": "rectangle", - "x": 480, - "y": 260, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1201, - "boundElements": [ - { - "id": "ar-a1a2", - "type": "arrow" - }, - { - "id": "ar-a2a3", - "type": "arrow" - } - ], - "index": "a030", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-url", - "type": "rectangle", - "x": 480, - "y": 260, - "width": 300, - "height": 26, - "strokeColor": "transparent", - "backgroundColor": "#eceef1", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1202, - "index": "a031", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-urlt", - "type": "text", - "x": 496, - "y": 268, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1203, - "text": "badges.simplex.chat/#/tier?app=true", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a032", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/#/tier?app=true", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a2-title", - "type": "text", - "x": 496, - "y": 312, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1204, - "text": "Choose your badge", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a033", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Choose your badge", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a2-lede", - "type": "text", - "x": 496, - "y": 342, - "width": 268, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1205, - "text": "Larger files that stay available longer.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a034", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Larger files that stay available longer.", - "lineHeight": 1.368421052631579 - }, - { - "id": "a2-c1", - "type": "rectangle", - "x": 496, - "y": 378, - "width": 130, - "height": 150, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1206, - "index": "a035", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-c1i", - "type": "rectangle", - "x": 545, - "y": 398, - "width": 32, - "height": 32, - "strokeColor": "transparent", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1207, - "index": "a036", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-c1t", - "type": "text", - "x": 504, - "y": 442, - "width": 114, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1208, - "text": "Supporter\n2GB files\n\n$7/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a037", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Supporter\n2GB files\n\n$7/month", - "lineHeight": 1.3 - }, - { - "id": "a2-c2", - "type": "rectangle", - "x": 634, - "y": 378, - "width": 130, - "height": 150, - "strokeColor": "#0053d0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1209, - "index": "a038", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-c2i", - "type": "rectangle", - "x": 683, - "y": 398, - "width": 32, - "height": 32, - "strokeColor": "transparent", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1210, - "index": "a039", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-c2t", - "type": "text", - "x": 642, - "y": 442, - "width": 114, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1211, - "text": "Legend\n5GB files\n\n$70/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a03a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend\n5GB files\n\n$70/month", - "lineHeight": 1.3 - }, - { - "id": "a2-btn", - "type": "rectangle", - "x": 496, - "y": 560, - "width": 268, - "height": 36, - "strokeColor": "transparent", - "backgroundColor": "#0053d0", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1212, - "boundElements": [ - { - "id": "a2-btnt", - "type": "text" - } - ], - "index": "a03b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a2-btnt", - "type": "text", - "x": 506, - "y": 571, - "width": 248, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1213, - "text": "Continue", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a2-btn", - "index": "a03c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a2-capt", - "type": "text", - "x": 480, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1215, - "text": "A2. Where the link lands", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a03d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A2. Where the link lands", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a2-capb", - "type": "text", - "x": 480, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A2", - "lane1" - ], - "seed": 1216, - "text": "The page's own second screen, quoted as it is. The flag\nin #/tier?app=true skips its Support SimpleX, which the\napp has just shown. NEW on the page: read that flag, start\nhere, and carry it: the session to checkout, then the order.\nThe app builds the URL, so it can also say which kind of\napp it is - better than guessing from the user agent.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a03e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The page's own second screen, quoted as it is. The flag\nin #/tier?app=true skips its Support SimpleX, which the\napp has just shown. NEW on the page: read that flag, start\nhere, and carry it: the session to checkout, then the order.\nThe app builds the URL, so it can also say which kind of\napp it is - better than guessing from the user agent.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "a3-panel", - "type": "rectangle", - "x": 900, - "y": 260, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1301, - "boundElements": [ - { - "id": "ar-a2a3", - "type": "arrow" - }, - { - "id": "ar-a3a4", - "type": "arrow" - } - ], - "index": "a040", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-url", - "type": "rectangle", - "x": 900, - "y": 260, - "width": 300, - "height": 26, - "strokeColor": "transparent", - "backgroundColor": "#eceef1", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1302, - "index": "a041", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-urlt", - "type": "text", - "x": 916, - "y": 268, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1303, - "text": "badges.simplex.chat/#/tier?app=true", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a042", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/#/tier?app=true", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a3-title", - "type": "text", - "x": 916, - "y": 312, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1304, - "text": "How long?", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a043", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "How long?", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a3-lede", - "type": "text", - "x": 916, - "y": 342, - "width": 268, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1305, - "text": "Legend - 5GB files, 21 days", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a044", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend - 5GB files, 21 days", - "lineHeight": 1.368421052631579 - }, - { - "id": "a3-c1", - "type": "rectangle", - "x": 916, - "y": 378, - "width": 84, - "height": 124, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1306, - "index": "a045", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-c1t", - "type": "text", - "x": 920, - "y": 418, - "width": 76, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1307, - "text": "1 month\n\n$70", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a046", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "1 month\n\n$70", - "lineHeight": 1.3 - }, - { - "id": "a3-c2", - "type": "rectangle", - "x": 1008, - "y": 378, - "width": 84, - "height": 124, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1308, - "index": "a047", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-c2t", - "type": "text", - "x": 1012, - "y": 412, - "width": 76, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1309, - "text": "3 months\n\n$140\none month free", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a048", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "3 months\n\n$140\none month free", - "lineHeight": 1.3 - }, - { - "id": "a3-c3", - "type": "rectangle", - "x": 1100, - "y": 378, - "width": 84, - "height": 124, - "strokeColor": "#0053d0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1310, - "index": "a049", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-c3t", - "type": "text", - "x": 1104, - "y": 412, - "width": 76, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1311, - "text": "12 months\n\n$420\nsave 50%", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a04a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "12 months\n\n$420\nsave 50%", - "lineHeight": 1.3 - }, - { - "id": "a3-note", - "type": "text", - "x": 916, - "y": 520, - "width": 268, - "height": 24, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1312, - "text": "Paid once, for the months you choose.\nNo subscription.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a04b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paid once, for the months you choose.\nNo subscription.", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a3-btn", - "type": "rectangle", - "x": 916, - "y": 560, - "width": 268, - "height": 36, - "strokeColor": "transparent", - "backgroundColor": "#0053d0", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1313, - "boundElements": [ - { - "id": "a3-btnt", - "type": "text" - } - ], - "index": "a04c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a3-btnt", - "type": "text", - "x": 926, - "y": 571, - "width": 248, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1314, - "text": "Continue", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a3-btn", - "index": "a04d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a3-capt", - "type": "text", - "x": 900, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1315, - "text": "A3. Unchanged", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a04e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A3. Unchanged", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a3-capb", - "type": "text", - "x": 900, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A3", - "lane1" - ], - "seed": 1316, - "text": "The page's own screen, and the source of the names this\nplan adopts in the app: Choose your badge, How long?,\nCheck your order. Prices and offers are the compiled catalog\nthe page already has, so getBadgeCatalog is not needed by\nanyone in this lane.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a04f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The page's own screen, and the source of the names this\nplan adopts in the app: Choose your badge, How long?,\nCheck your order. Prices and offers are the compiled catalog\nthe page already has, so getBadgeCatalog is not needed by\nanyone in this lane.", - "lineHeight": 1.4666666666666666 - }, - { - "id": "a4-panel", - "type": "rectangle", - "x": 1320, - "y": 260, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1401, - "boundElements": [ - { - "id": "ar-a3a4", - "type": "arrow" - }, - { - "id": "ar-a4a5", - "type": "arrow" - } - ], - "index": "a050", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-url", - "type": "rectangle", - "x": 1320, - "y": 260, - "width": 300, - "height": 26, - "strokeColor": "transparent", - "backgroundColor": "#eceef1", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1402, - "index": "a051", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-urlt", - "type": "text", - "x": 1336, - "y": 268, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1403, - "text": "badges.simplex.chat/?order=8f3a...", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a052", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/?order=8f3a...", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a4-title", - "type": "text", - "x": 1336, - "y": 312, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1404, - "text": "Check your order", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a053", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Check your order", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a4-rowsL", - "type": "text", - "x": 1336, - "y": 352, - "width": 160, - "height": 48, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1405, - "text": "Your badge\nDuration\nLaunch discount: 50% off\nTotal", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a054", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Your badge\nDuration\nLaunch discount: 50% off\nTotal", - "lineHeight": 1.263157894736842 - }, - { - "id": "a4-rowsR", - "type": "text", - "x": 1508, - "y": 352, - "width": 96, - "height": 48, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1406, - "text": "Legend\n12 months\n-$420.00\n$420.00", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "right", - "verticalAlign": "top", - "autoResize": false, - "index": "a055", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend\n12 months\n-$420.00\n$420.00", - "lineHeight": 1.263157894736842 - }, - { - "id": "a4-label", - "type": "text", - "x": 1336, - "y": 420, - "width": 100, - "height": 12, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1407, - "text": "PAY WITH", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a056", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "PAY WITH", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a4-m1", - "type": "rectangle", - "x": 1336, - "y": 438, - "width": 84, - "height": 60, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1408, - "index": "a057", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-m1t", - "type": "text", - "x": 1340, - "y": 460, - "width": 76, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1409, - "text": "Bitcoin", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a058", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Bitcoin", - "lineHeight": 1.3 - }, - { - "id": "a4-m2", - "type": "rectangle", - "x": 1428, - "y": 438, - "width": 84, - "height": 60, - "strokeColor": "#0053d0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1410, - "index": "a059", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-m2t", - "type": "text", - "x": 1432, - "y": 460, - "width": 76, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1411, - "text": "Monero", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a05a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Monero", - "lineHeight": 1.3 - }, - { - "id": "a4-m3", - "type": "rectangle", - "x": 1520, - "y": 438, - "width": 84, - "height": 60, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1412, - "index": "a05b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-m3t", - "type": "text", - "x": 1524, - "y": 460, - "width": 76, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1413, - "text": "Card", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a05c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Card", - "lineHeight": 1.3 - }, - { - "id": "a4-btn", - "type": "rectangle", - "x": 1336, - "y": 560, - "width": 268, - "height": 36, - "strokeColor": "transparent", - "backgroundColor": "#0053d0", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1414, - "boundElements": [ - { - "id": "a4-btnt", - "type": "text" - } - ], - "index": "a05d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a4-btnt", - "type": "text", - "x": 1346, - "y": 571, - "width": 248, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1415, - "text": "Pay $420.00 with Monero", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a4-btn", - "index": "a05e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Pay $420.00 with Monero", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a4-capt", - "type": "text", - "x": 1320, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1416, - "text": "A4. Unchanged, and the pay screens after it", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a05f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A4. Unchanged, and the pay screens after it", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a4-capb", - "type": "text", - "x": 1320, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A4", - "lane1" - ], - "seed": 1417, - "text": "The method is picked here and the code is minted here, as\ntoday: the browser generates it and sends only its SHA-256.\nSend 1.482 XMR, the countdown and Payment received follow\nand are unchanged, so they are not drawn. The service needs\nno new API for this lane at all.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a05g", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The method is picked here and the code is minted here, as\ntoday: the browser generates it and sends only its SHA-256.\nSend 1.482 XMR, the countdown and Payment received follow\nand are unchanged, so they are not drawn. The service needs\nno new API for this lane at all.", - "lineHeight": 1.4666666666666666 - }, - { - "id": "a5-panel", - "type": "rectangle", - "x": 1740, - "y": 260, - "width": 300, - "height": 620, - "strokeColor": "#0053d0", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1501, - "boundElements": [ - { - "id": "ar-a4a5", - "type": "arrow" - }, - { - "id": "ar-a5a6", - "type": "arrow" - } - ], - "index": "a060", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a5-url", - "type": "rectangle", - "x": 1740, - "y": 260, - "width": 300, - "height": 26, - "strokeColor": "transparent", - "backgroundColor": "#eceef1", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1502, - "index": "a061", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a5-urlt", - "type": "text", - "x": 1756, - "y": 268, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1503, - "text": "badges.simplex.chat/?order=8f3a...", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a062", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/?order=8f3a...", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a5-title", - "type": "text", - "x": 1756, - "y": 312, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1504, - "text": "Paid", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a063", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paid", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a5-lede", - "type": "text", - "x": 1756, - "y": 344, - "width": 268, - "height": 26, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1505, - "text": "Opening SimpleX to add your badge.\nIf nothing happens, use the button.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a064", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Opening SimpleX to add your badge.\nIf nothing happens, use the button.", - "lineHeight": 1.368421052631579 - }, - { - "id": "a5-btn", - "type": "rectangle", - "x": 1756, - "y": 396, - "width": 268, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#0053d0", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1506, - "boundElements": [ - { - "id": "a5-btnt", - "type": "text" - } - ], - "index": "a065", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a5-btnt", - "type": "text", - "x": 1766, - "y": 408, - "width": 248, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1507, - "text": "Return to SimpleX", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "a5-btn", - "index": "a066", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Return to SimpleX", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "a5-sec", - "type": "text", - "x": 1756, - "y": 452, - "width": 268, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1508, - "text": "Show code", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a067", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Show code", - "lineHeight": 1.3 - }, - { - "id": "a5-link", - "type": "rectangle", - "x": 1756, - "y": 496, - "width": 268, - "height": 34, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1509, - "index": "a068", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a5-linkt", - "type": "text", - "x": 1762, - "y": 507, - "width": 256, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1510, - "text": "simplexchat:/badge/code/SB-4K2M9-...", - "fontSize": 9, - "fontFamily": 3, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a069", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "simplexchat:/badge/code/SB-4K2M9-...", - "lineHeight": 1.4444444444444444 - }, - { - "id": "a5-note", - "type": "text", - "x": 1756, - "y": 544, - "width": 268, - "height": 33, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1511, - "text": "the page tries this on load; the button is\nthe backup, and Show code is the\nbackup to the backup", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a06a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "the page tries this on load; the button is\nthe backup, and Show code is the\nbackup to the backup", - "lineHeight": 1.2222222222222223 - }, - { - "id": "a5-capt", - "type": "text", - "x": 1740, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1512, - "text": "A5. New: the code leaves by link, not by eye", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a06b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A5. New: the code leaves by link, not by eye", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a5-capb", - "type": "text", - "x": 1740, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A5", - "lane1" - ], - "seed": 1513, - "text": "Replaces codeIssued for a mobile app purchase. The flag is\nnot in the URL here - it rode the session to checkout and is\nstamped on the order. The code is still minted and saved, so\nShow code recovers it if the link fails; a code that could\nnot be saved gets the plain screen instead. simplexchat: is a\nscheme of its own, so a badge link can never be read as a\nconnection link. The attempt is silent where it is blocked.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a06c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Replaces codeIssued for a mobile app purchase. The flag is\nnot in the URL here - it rode the session to checkout and is\nstamped on the order. The code is still minted and saved, so\nShow code recovers it if the link fails; a code that could\nnot be saved gets the plain screen instead. simplexchat: is a\nscheme of its own, so a badge link can never be read as a\nconnection link. The attempt is silent where it is blocked.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "a6-panel", - "type": "rectangle", - "x": 2160, - "y": 260, - "width": 300, - "height": 620, - "strokeColor": "#0053d0", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1601, - "boundElements": [ - { - "id": "ar-a5a6", - "type": "arrow" - } - ], - "index": "a070", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-title", - "type": "text", - "x": 2180, - "y": 306, - "width": 260, - "height": 24, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1602, - "text": "Badge is being issued", - "fontSize": 18, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a071", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Badge is being issued", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a6-spin", - "type": "ellipse", - "x": 2288, - "y": 380, - "width": 44, - "height": 44, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1603, - "index": "a072", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-spint", - "type": "text", - "x": 2180, - "y": 440, - "width": 260, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1604, - "text": "progress", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a073", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "progress", - "lineHeight": 1.3333333333333333 - }, - { - "id": "a6-body", - "type": "text", - "x": 2186, - "y": 478, - "width": 248, - "height": 39, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1605, - "text": "This takes a moment. You do not\nneed to do anything, and you can\nclose the app.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a074", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "This takes a moment. You do not\nneed to do anything, and you can\nclose the app.", - "lineHeight": 1.368421052631579 - }, - { - "id": "a6-then", - "type": "rectangle", - "x": 2176, - "y": 600, - "width": 268, - "height": 96, - "strokeColor": "#8f97a3", - "backgroundColor": "#f2f3f5", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1606, - "index": "a075", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-theni", - "type": "rectangle", - "x": 2190, - "y": 612, - "width": 240, - "height": 72, - "strokeColor": "transparent", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1607, - "index": "a076", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-theng", - "type": "rectangle", - "x": 2298, - "y": 620, - "width": 24, - "height": 24, - "strokeColor": "transparent", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1608, - "index": "a077", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "a6-thent", - "type": "text", - "x": 2180, - "y": 650, - "width": 260, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1609, - "text": "Legend", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a078", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend", - "lineHeight": 1.3 - }, - { - "id": "a6-thens", - "type": "text", - "x": 2180, - "y": 666, - "width": 260, - "height": 11, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1610, - "text": "shown on your profile", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a079", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "shown on your profile", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a6-thenn", - "type": "text", - "x": 2160, - "y": 708, - "width": 300, - "height": 11, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1611, - "text": "then BadgesYourBadgeView, unchanged", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a07a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "then BadgesYourBadgeView, unchanged", - "lineHeight": 1.2941176470588236 - }, - { - "id": "a6-capt", - "type": "text", - "x": 2160, - "y": 900, - "width": 300, - "height": 14, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1612, - "text": "A6. New screen, and the only new app state", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a07b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "A6. New screen, and the only new app state", - "lineHeight": 1.2727272727272727 - }, - { - "id": "a6-capb", - "type": "text", - "x": 2160, - "y": 920, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "A6", - "lane1" - ], - "seed": 1613, - "text": "The deep link lands here, not on Redeem code: a pasted code\nand a QR scanner make no sense when the code arrived by\nlink. It calls the existing apiRedeemBadgeCode and shows its\nfailures as that screen does. Naming: Redeeming code names\nthe mechanism, Badge is being issued names the outcome -\nand the outcome is what the buyer cares about.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a07c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The deep link lands here, not on Redeem code: a pasted code\nand a QR scanner make no sense when the code arrived by\nlink. It calls the existing apiRedeemBadgeCode and shows its\nfailures as that screen does. Naming: Redeeming code names\nthe mechanism, Badge is being issued names the outcome -\nand the outcome is what the buyer cares about.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "ar-a1a2", - "type": "arrow", - "x": 368, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1701, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a1a2-t", - "type": "text" - } - ], - "index": "a080", - "version": 2, - "versionNonce": 1538965901, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251194517, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a1a2-t", - "type": "text", - "x": 365.810546875, - "y": 563.5, - "width": 108.37890625, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1702, - "text": "Buy in browser - no alert", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a1a2", - "index": "a081", - "version": 3, - "versionNonce": 709045891, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251191288, - "link": null, - "locked": false, - "originalText": "Buy in browser - no alert", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-a2a3", - "type": "arrow", - "x": 788, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1703, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a2a3-t", - "type": "text" - } - ], - "index": "a082", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a2a3-t", - "type": "text", - "x": 800, - "y": 556, - "width": 80, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1704, - "text": "Continue", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a2a3", - "index": "a083", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-a3a4", - "type": "arrow", - "x": 1208, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1705, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a3a4-t", - "type": "text" - } - ], - "index": "a084", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a3a4-t", - "type": "text", - "x": 1220, - "y": 556, - "width": 80, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1706, - "text": "Continue", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a3a4", - "index": "a085", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-a4a5", - "type": "arrow", - "x": 1628, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1707, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a4a5-t", - "type": "text" - } - ], - "index": "a086", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a4a5-t", - "type": "text", - "x": 1640, - "y": 556, - "width": 80, - "height": 13, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1708, - "text": "it settles", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a4a5", - "index": "a087", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "it settles", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-a5a6", - "type": "arrow", - "x": 2048, - "y": 570, - "width": 104, - "height": 0, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 1709, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-a5a6-t", - "type": "text" - } - ], - "index": "a088", - "version": 2, - "versionNonce": 737650275, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251382068, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-a5a6-t", - "type": "text", - "x": 2056.93115234375, - "y": 557, - "width": 86.1376953125, - "height": 26, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 1710, - "text": "simplexchat: carries\nthe code", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-a5a6", - "index": "a089", - "version": 4, - "versionNonce": 1743168653, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251380246, - "link": null, - "locked": false, - "originalText": "simplexchat: carries the code", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "l2-band", - "type": "rectangle", - "x": 20, - "y": 1040, - "width": 2460, - "height": 850, - "strokeColor": "transparent", - "backgroundColor": "#f5f1ff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "lane2" - ], - "seed": 2010, - "index": "a100", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "l2-head", - "type": "text", - "x": 60, - "y": 1068, - "width": 900, - "height": 22, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane2" - ], - "seed": 2011, - "text": "Store builds - the store path in the app, the browser alongside it", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a101", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Store builds - the store path in the app, the browser alongside it", - "lineHeight": 1.2941176470588236 - }, - { - "id": "l2-sub", - "type": "text", - "x": 60, - "y": 1096, - "width": 1300, - "height": 14, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane2" - ], - "seed": 2012, - "text": "Buy in browser runs lane A unchanged; only these four screens and the outcomes after them are in-app, and only here", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a102", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Buy in browser runs lane A unchanged; only these four screens and the outcomes after them are in-app, and only here", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b1-panel", - "type": "rectangle", - "x": 60, - "y": 1150, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2101, - "boundElements": [ - { - "id": "ar-b1b2", - "type": "arrow" - } - ], - "index": "a110", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b1-title", - "type": "text", - "x": 80, - "y": 1196, - "width": 260, - "height": 24, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2102, - "text": "Support SimpleX", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a111", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Support SimpleX", - "lineHeight": 1.263157894736842 - }, - { - "id": "b1-body", - "type": "text", - "x": 76, - "y": 1238, - "width": 268, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2103, - "text": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a112", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.", - "lineHeight": 1.3 - }, - { - "id": "b1-info", - "type": "text", - "x": 80, - "y": 1294, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2104, - "text": "(i) How badges protect your privacy", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a113", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "(i) How badges protect your privacy", - "lineHeight": 1.3 - }, - { - "id": "b1-hero", - "type": "rectangle", - "x": 132, - "y": 1338, - "width": 156, - "height": 156, - "strokeColor": "transparent", - "backgroundColor": "#efeaf9", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2105, - "index": "a114", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b1-herot", - "type": "text", - "x": 132, - "y": 1409, - "width": 156, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2106, - "text": "phone-supporter", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a115", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "phone-supporter", - "lineHeight": 1.3333333333333333 - }, - { - "id": "b1-btn", - "type": "rectangle", - "x": 90, - "y": 1620, - "width": 240, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#5b21b6", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2107, - "boundElements": [ - { - "id": "b1-btnt", - "type": "text" - } - ], - "index": "a116", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b1-btnt", - "type": "text", - "x": 100, - "y": 1632, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2108, - "text": "Choose your badge", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "b1-btn", - "index": "a117", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Choose your badge", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "b1-sec", - "type": "text", - "x": 80, - "y": 1676, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2109, - "text": "Buy in browser", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a118", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Buy in browser", - "lineHeight": 1.3 - }, - { - "id": "b1-secn", - "type": "text", - "x": 80, - "y": 1696, - "width": 260, - "height": 24, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2110, - "text": "where the browser is not allowed,\nthis slot reads Redeem code instead", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a119", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "where the browser is not allowed,\nthis slot reads Redeem code instead", - "lineHeight": 1.3333333333333333 - }, - { - "id": "b1-capt", - "type": "text", - "x": 60, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2111, - "text": "B1. Two buttons, one of them gated", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a11a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B1. Two buttons, one of them gated", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b1-capb", - "type": "text", - "x": 60, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B1", - "lane2" - ], - "seed": 2112, - "text": "Buy in browser is the same tap as A1's and runs the same\nlane, but only where the store rules allow it - a wider set of\nmarkets than the crowdfunding banner covers, and not pinned\ndown yet. Where it is not allowed the slot reads Redeem code\ninstead, so this screen always offers one of the two and codes\nare never out of reach.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a11b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Buy in browser is the same tap as A1's and runs the same\nlane, but only where the store rules allow it - a wider set of\nmarkets than the crowdfunding banner covers, and not pinned\ndown yet. Where it is not allowed the slot reads Redeem code\ninstead, so this screen always offers one of the two and codes\nare never out of reach.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "b2-panel", - "type": "rectangle", - "x": 480, - "y": 1150, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2201, - "boundElements": [ - { - "id": "ar-b1b2", - "type": "arrow" - }, - { - "id": "ar-b2b3", - "type": "arrow" - } - ], - "index": "a120", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-back", - "type": "text", - "x": 496, - "y": 1168, - "width": 20, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2202, - "text": "<", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a121", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "<", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b2-title", - "type": "text", - "x": 500, - "y": 1196, - "width": 260, - "height": 24, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2203, - "text": "Choose your badge", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a122", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Choose your badge", - "lineHeight": 1.263157894736842 - }, - { - "id": "b2-lede", - "type": "text", - "x": 500, - "y": 1236, - "width": 260, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2204, - "text": "Larger files that stay available longer.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a123", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Larger files that stay available longer.", - "lineHeight": 1.368421052631579 - }, - { - "id": "b2-avatar", - "type": "ellipse", - "x": 598, - "y": 1268, - "width": 56, - "height": 56, - "strokeColor": "transparent", - "backgroundColor": "#e3e6ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2205, - "index": "a124", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-name", - "type": "text", - "x": 500, - "y": 1334, - "width": 260, - "height": 15, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2206, - "text": "My nickname [badge] v", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a125", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "My nickname [badge] v", - "lineHeight": 1.25 - }, - { - "id": "b2-c1", - "type": "rectangle", - "x": 492, - "y": 1378, - "width": 132, - "height": 150, - "strokeColor": "#5b21b6", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2207, - "index": "a126", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-c1i", - "type": "rectangle", - "x": 542, - "y": 1398, - "width": 32, - "height": 32, - "strokeColor": "transparent", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2208, - "index": "a127", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-c1t", - "type": "text", - "x": 500, - "y": 1442, - "width": 116, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2209, - "text": "Supporter\nSend 2GB files\n\n$6.99/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a128", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Supporter\nSend 2GB files\n\n$6.99/month", - "lineHeight": 1.3 - }, - { - "id": "b2-c2", - "type": "rectangle", - "x": 636, - "y": 1378, - "width": 132, - "height": 150, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2210, - "index": "a129", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-c2i", - "type": "rectangle", - "x": 686, - "y": 1398, - "width": 32, - "height": 32, - "strokeColor": "transparent", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2211, - "index": "a12a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-c2t", - "type": "text", - "x": 644, - "y": 1442, - "width": 116, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2212, - "text": "Legend\nSend 5GB files\n\n$69.99/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a12b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend\nSend 5GB files\n\n$69.99/month", - "lineHeight": 1.3 - }, - { - "id": "b2-btn", - "type": "rectangle", - "x": 510, - "y": 1596, - "width": 240, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#5b21b6", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2213, - "boundElements": [ - { - "id": "b2-btnt", - "type": "text" - } - ], - "index": "a12c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001521, - "link": null, - "locked": false - }, - { - "id": "b2-btnt", - "type": "text", - "x": 520, - "y": 1608, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2214, - "text": "Continue", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "b2-btn", - "index": "a12d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "b2-sec", - "type": "text", - "x": 500, - "y": 1652, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2215, - "text": "Redeem code", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a12e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Redeem code", - "lineHeight": 1.3 - }, - { - "id": "b2-infon", - "type": "text", - "x": 500, - "y": 1676, - "width": 260, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2216, - "text": "here only when B1 offers the browser;\nthe privacy link always stays on B1", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a12f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "here only when B1 offers the browser;\nthe privacy link always stays on B1", - "lineHeight": 1.3333333333333333 - }, - { - "id": "b2-capt", - "type": "text", - "x": 480, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2217, - "text": "B2. BadgesChooseBadgeView", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a130", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B2. BadgesChooseBadgeView", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b2-capb", - "type": "text", - "x": 480, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B2", - "lane2" - ], - "seed": 2218, - "text": "Your level becomes Choose your badge, with the page's lede.\nRedeem code sits here only when B1's slot is taken by Buy in\nbrowser; where the browser is not allowed it stays on B1 and\nthis screen has Continue alone. The privacy link never moves -\nit stays on Support SimpleX in both builds.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a131", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001521, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Your level becomes Choose your badge, with the page's lede.\nRedeem code sits here only when B1's slot is taken by Buy in\nbrowser; where the browser is not allowed it stays on B1 and\nthis screen has Continue alone. The privacy link never moves -\nit stays on Support SimpleX in both builds.", - "lineHeight": 1.4666666666666666 - }, - { - "id": "b3-panel", - "type": "rectangle", - "x": 900, - "y": 1150, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2301, - "boundElements": [ - { - "id": "ar-b2b3", - "type": "arrow" - }, - { - "id": "ar-b3b4", - "type": "arrow" - } - ], - "index": "a132", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-back", - "type": "text", - "x": 916, - "y": 1168, - "width": 20, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2302, - "text": "<", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a133", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "<", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b3-title", - "type": "text", - "x": 920, - "y": 1196, - "width": 260, - "height": 24, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2303, - "text": "How long?", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a134", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "How long?", - "lineHeight": 1.263157894736842 - }, - { - "id": "b3-lede", - "type": "text", - "x": 920, - "y": 1236, - "width": 260, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2304, - "text": "Legend - 5GB files, 21 days", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a135", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend - 5GB files, 21 days", - "lineHeight": 1.368421052631579 - }, - { - "id": "b3-avatar", - "type": "ellipse", - "x": 1022, - "y": 1268, - "width": 56, - "height": 56, - "strokeColor": "transparent", - "backgroundColor": "#e3e6ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2305, - "index": "a136", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-name", - "type": "text", - "x": 920, - "y": 1334, - "width": 260, - "height": 15, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2306, - "text": "My nickname [badge]", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a137", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "My nickname [badge]", - "lineHeight": 1.25 - }, - { - "id": "b3-c1", - "type": "rectangle", - "x": 912, - "y": 1386, - "width": 85, - "height": 124, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2307, - "index": "a138", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-c1t", - "type": "text", - "x": 917, - "y": 1426, - "width": 75, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2308, - "text": "1 month\n\n$69.99", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a139", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "1 month\n\n$69.99", - "lineHeight": 1.3 - }, - { - "id": "b3-c2", - "type": "rectangle", - "x": 1007, - "y": 1386, - "width": 85, - "height": 124, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2309, - "index": "a13a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-c2t", - "type": "text", - "x": 1010, - "y": 1426, - "width": 79, - "height": 39, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2310, - "text": "Monthly\n\n$69.99/month", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a13b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Monthly\n\n$69.99/month", - "lineHeight": 1.3 - }, - { - "id": "b3-c3", - "type": "rectangle", - "x": 1102, - "y": 1386, - "width": 85, - "height": 124, - "strokeColor": "#5b21b6", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 2, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2311, - "index": "a13c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-c3t", - "type": "text", - "x": 1105, - "y": 1420, - "width": 79, - "height": 52, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2312, - "text": "Annual\n\n$699.99/year\nSave 17%", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a13d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Annual\n\n$699.99/year\nSave 17%", - "lineHeight": 1.3 - }, - { - "id": "b3-btn", - "type": "rectangle", - "x": 930, - "y": 1620, - "width": 240, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#5b21b6", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2313, - "boundElements": [ - { - "id": "b3-btnt", - "type": "text" - } - ], - "index": "a13e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b3-btnt", - "type": "text", - "x": 940, - "y": 1632, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2314, - "text": "Continue", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "b3-btn", - "index": "a13f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "b3-foot", - "type": "text", - "x": 920, - "y": 1676, - "width": 260, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2315, - "text": "Renews on 24 August 2027. Cancel anytime.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a140", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Renews on 24 August 2027. Cancel anytime.", - "lineHeight": 1.368421052631579 - }, - { - "id": "b3-capt", - "type": "text", - "x": 900, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2316, - "text": "B3. BadgesHowLongView, disarmed", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a141", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B3. BadgesHowLongView, disarmed", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b3-capb", - "type": "text", - "x": 900, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B3", - "lane2" - ], - "seed": 2317, - "text": "The level name was the title; now How long? is, with the\nchosen badge on the line under it, as the page does it.\nPay $69.99 becomes Continue and purchase() moves to B4.\nBadgePeriod as written today: one-time, monthly, annual,\nprices from StoreKit and Play, which need not match the web\npage's and generally will not.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a142", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The level name was the title; now How long? is, with the\nchosen badge on the line under it, as the page does it.\nPay $69.99 becomes Continue and purchase() moves to B4.\nBadgePeriod as written today: one-time, monthly, annual,\nprices from StoreKit and Play, which need not match the web\npage's and generally will not.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "b4-panel", - "type": "rectangle", - "x": 1320, - "y": 1150, - "width": 300, - "height": 620, - "strokeColor": "#5b21b6", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2401, - "boundElements": [ - { - "id": "ar-b3b4", - "type": "arrow" - }, - { - "id": "ar-b4o1", - "type": "arrow" - }, - { - "id": "ar-b4o2", - "type": "arrow" - }, - { - "id": "ar-b4o3", - "type": "arrow" - } - ], - "index": "a143", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b4-back", - "type": "text", - "x": 1336, - "y": 1168, - "width": 20, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2402, - "text": "<", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a144", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "<", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b4-title", - "type": "text", - "x": 1340, - "y": 1196, - "width": 260, - "height": 24, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2403, - "text": "Check your order", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a145", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Check your order", - "lineHeight": 1.263157894736842 - }, - { - "id": "b4-rowsL", - "type": "text", - "x": 1344, - "y": 1270, - "width": 160, - "height": 36, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2404, - "text": "Your badge\nDuration\nTotal", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a146", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Your badge\nDuration\nTotal", - "lineHeight": 1.263157894736842 - }, - { - "id": "b4-rowsR", - "type": "text", - "x": 1500, - "y": 1270, - "width": 96, - "height": 36, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2405, - "text": "Legend\nAnnual\n$699.99/year", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "right", - "verticalAlign": "top", - "autoResize": false, - "index": "a147", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend\nAnnual\n$699.99/year", - "lineHeight": 1.263157894736842 - }, - { - "id": "b4-div", - "type": "rectangle", - "x": 1344, - "y": 1292, - "width": 252, - "height": 1, - "strokeColor": "#dfe4ea", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": null, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2406, - "index": "a148", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b4-note", - "type": "text", - "x": 1344, - "y": 1336, - "width": 252, - "height": 26, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2407, - "text": "Charged by the App Store to the\naccount on this device.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a149", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Charged by the App Store to the\naccount on this device.", - "lineHeight": 1.368421052631579 - }, - { - "id": "b4-btn", - "type": "rectangle", - "x": 1350, - "y": 1620, - "width": 240, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#5b21b6", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2408, - "boundElements": [ - { - "id": "b4-btnt", - "type": "text" - } - ], - "index": "a14a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "b4-btnt", - "type": "text", - "x": 1360, - "y": 1632, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2409, - "text": "Pay $699.99/year", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "b4-btn", - "index": "a14b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Pay $699.99/year", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "b4-foot", - "type": "text", - "x": 1340, - "y": 1676, - "width": 260, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2410, - "text": "Renews on 24 August 2027. Cancel anytime.", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a14c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Renews on 24 August 2027. Cancel anytime.", - "lineHeight": 1.368421052631579 - }, - { - "id": "b4-capt", - "type": "text", - "x": 1320, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2411, - "text": "B4. New screen, one for every market", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a14d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B4. New screen, one for every market", - "lineHeight": 1.2727272727272727 - }, - { - "id": "b4-capb", - "type": "text", - "x": 1320, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "B4", - "lane2" - ], - "seed": 2412, - "text": "No second button and nothing about paying elsewhere - that\nchoice was made back on B1. So this screen is identical in\nevery storefront and nothing on it is gated, which also means\nthe subscription never has to be reconciled against a web\nprice.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a14e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "No second button and nothing about paying elsewhere - that\nchoice was made back on B1. So this screen is identical in\nevery storefront and nothing on it is gated, which also means\nthe subscription never has to be reconciled against a web\nprice.", - "lineHeight": 1.4666666666666666 - }, - { - "id": "o1-card", - "type": "rectangle", - "x": 1740, - "y": 1160, - "width": 300, - "height": 120, - "strokeColor": "#5b21b6", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "O1", - "lane2" - ], - "seed": 2601, - "boundElements": [ - { - "id": "ar-b4o1", - "type": "arrow" - } - ], - "index": "a150", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o1-t", - "type": "text", - "x": 1760, - "y": 1174, - "width": 260, - "height": 17, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O1", - "lane2" - ], - "seed": 2602, - "text": "Paid", - "fontSize": 13, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a151", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paid", - "lineHeight": 1.3076923076923077 - }, - { - "id": "o1-s", - "type": "text", - "x": 1760, - "y": 1198, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O1", - "lane2" - ], - "seed": 2603, - "text": "o Issuing your badge", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a152", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "o Issuing your badge", - "lineHeight": 1.3 - }, - { - "id": "o1-b", - "type": "text", - "x": 1766, - "y": 1222, - "width": 248, - "height": 26, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O1", - "lane2" - ], - "seed": 2604, - "text": "The store has taken the payment.\nKeeps trying if you close the app.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a153", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The store has taken the payment.\nKeeps trying if you close the app.", - "lineHeight": 1.4444444444444444 - }, - { - "id": "o2-card", - "type": "rectangle", - "x": 1740, - "y": 1300, - "width": 300, - "height": 96, - "strokeColor": "#8f97a3", - "backgroundColor": "#f2f3f5", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2605, - "index": "a154", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o2-i", - "type": "rectangle", - "x": 1756, - "y": 1312, - "width": 268, - "height": 72, - "strokeColor": "transparent", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2606, - "index": "a155", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o2-g", - "type": "rectangle", - "x": 1878, - "y": 1320, - "width": 24, - "height": 24, - "strokeColor": "transparent", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2607, - "index": "a156", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o2-t", - "type": "text", - "x": 1760, - "y": 1350, - "width": 260, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2608, - "text": "Legend", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a157", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Legend", - "lineHeight": 1.3 - }, - { - "id": "o2-s", - "type": "text", - "x": 1760, - "y": 1366, - "width": 260, - "height": 11, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2609, - "text": "shown on your profile", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a158", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "shown on your profile", - "lineHeight": 1.2941176470588236 - }, - { - "id": "o2-n", - "type": "text", - "x": 1740, - "y": 1402, - "width": 300, - "height": 11, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O2", - "lane2" - ], - "seed": 2610, - "text": "BadgesYourBadgeView, unchanged", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a159", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "BadgesYourBadgeView, unchanged", - "lineHeight": 1.2941176470588236 - }, - { - "id": "o3-card", - "type": "rectangle", - "x": 1740, - "y": 1424, - "width": 300, - "height": 130, - "strokeColor": "#5b21b6", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "O3", - "lane2" - ], - "seed": 2611, - "boundElements": [ - { - "id": "ar-b4o2", - "type": "arrow" - } - ], - "index": "a15a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o3-t", - "type": "text", - "x": 1760, - "y": 1438, - "width": 260, - "height": 15, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O3", - "lane2" - ], - "seed": 2612, - "text": "Waiting for approval", - "fontSize": 12, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Waiting for approval", - "lineHeight": 1.25 - }, - { - "id": "o3-s", - "type": "text", - "x": 1760, - "y": 1460, - "width": 260, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O3", - "lane2" - ], - "seed": 2613, - "text": "o Nothing has been charged", - "fontSize": 9.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "o Nothing has been charged", - "lineHeight": 1.368421052631579 - }, - { - "id": "o3-b", - "type": "text", - "x": 1746, - "y": 1484, - "width": 288, - "height": 39, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O3", - "lane2" - ], - "seed": 2614, - "text": "Ask to Buy on iOS, a slow payment on\nPlay. Holds the slot so nothing can be\nbought twice, and becomes Paid above.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Ask to Buy on iOS, a slow payment on\nPlay. Holds the slot so nothing can be\nbought twice, and becomes Paid above.", - "lineHeight": 1.4444444444444444 - }, - { - "id": "o4-card", - "type": "rectangle", - "x": 1760, - "y": 1584, - "width": 260, - "height": 120, - "strokeColor": "#d6dae0", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2615, - "boundElements": [ - { - "id": "ar-b4o3", - "type": "arrow" - } - ], - "index": "a15e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o4-t", - "type": "text", - "x": 1776, - "y": 1598, - "width": 228, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2616, - "text": "Cannot verify this purchase", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15f", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Cannot verify this purchase", - "lineHeight": 1.2727272727272727 - }, - { - "id": "o4-b", - "type": "text", - "x": 1776, - "y": 1620, - "width": 228, - "height": 39, - "strokeColor": "#4b5563", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2617, - "text": "The store returned a transaction\nthat Apple has not signed. Nothing\nhas been bought.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15g", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The store returned a transaction\nthat Apple has not signed. Nothing\nhas been bought.", - "lineHeight": 1.4444444444444444 - }, - { - "id": "o4-d", - "type": "rectangle", - "x": 1760, - "y": 1668, - "width": 260, - "height": 1, - "strokeColor": "#dfe4ea", - "backgroundColor": "#dfe4ea", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": null, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2618, - "index": "a15h", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "o4-o", - "type": "text", - "x": 1760, - "y": 1678, - "width": 260, - "height": 14, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O4", - "lane2" - ], - "seed": 2619, - "text": "Ok", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15i", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Ok", - "lineHeight": 1.2727272727272727 - }, - { - "id": "o-note", - "type": "text", - "x": 1740, - "y": 1712, - "width": 300, - "height": 44, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O", - "lane2" - ], - "seed": 2620, - "text": "iOS only - Play does no local verification, so a bad\ntoken is refused by the service instead. cancelled:\nnothing. other errors: today's Purchase error alert.", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a15j", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "iOS only - Play does no local verification, so a bad\ntoken is refused by the service instead. cancelled:\nnothing. other errors: today's Purchase error alert.", - "lineHeight": 1.7254901960784312 - }, - { - "id": "o-capt", - "type": "text", - "x": 1740, - "y": 1790, - "width": 300, - "height": 14, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O", - "lane2" - ], - "seed": 2621, - "text": "B5. What the sheet returns", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a15k", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "B5. What the sheet returns", - "lineHeight": 1.2727272727272727 - }, - { - "id": "o-capb", - "type": "text", - "x": 1740, - "y": 1810, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "O", - "lane2" - ], - "seed": 2622, - "text": "Two of these are states on BadgeState, not alerts: Paid, which\nthe worker retries, and Waiting, which holds nothing but the\nslot - Support SimpleX must not offer a second purchase of a\nconsumable. Both resolve through the store, not the service:\nTransaction.updates on iOS, queryPurchasesAsync on Play,\nwhich is also how each is re-found after a restart.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a15l", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Two of these are states on BadgeState, not alerts: Paid, which\nthe worker retries, and Waiting, which holds nothing but the\nslot - Support SimpleX must not offer a second purchase of a\nconsumable. Both resolve through the store, not the service:\nTransaction.updates on iOS, queryPurchasesAsync on Play,\nwhich is also how each is re-found after a restart.", - "lineHeight": 1.2222222222222223 - }, - { - "id": "ar-b1b2", - "type": "arrow", - "x": 368, - "y": 1460, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2701, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b1b2-t", - "type": "text" - } - ], - "index": "a180", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b1b2-t", - "type": "text", - "x": 380, - "y": 1446, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2702, - "text": "Choose your badge", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b1b2", - "index": "a181", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Choose your badge", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b2b3", - "type": "arrow", - "x": 788, - "y": 1460, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2703, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b2b3-t", - "type": "text" - } - ], - "index": "a182", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b2b3-t", - "type": "text", - "x": 800, - "y": 1446, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2704, - "text": "Continue", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b2b3", - "index": "a183", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b3b4", - "type": "arrow", - "x": 1208, - "y": 1460, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2705, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b3b4-t", - "type": "text" - } - ], - "index": "a184", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b3b4-t", - "type": "text", - "x": 1220, - "y": 1446, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2706, - "text": "Continue", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b3b4", - "index": "a185", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Continue", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b4o1", - "type": "arrow", - "x": 1628, - "y": 1220, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2709, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b4o1-t", - "type": "text" - } - ], - "index": "a186", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b4o1-t", - "type": "text", - "x": 1640, - "y": 1206, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2710, - "text": "verified", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b4o1", - "index": "a187", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "verified", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b4o2", - "type": "arrow", - "x": 1628, - "y": 1489, - "width": 104, - "height": 0, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "strokeWidth": 2, - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2711, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b4o2-t", - "type": "text" - } - ], - "index": "a188", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b4o2-t", - "type": "text", - "x": 1640, - "y": 1475, - "width": 80, - "height": 13, - "strokeColor": "#5b21b6", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2712, - "text": "pending", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b4o2", - "index": "a189", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "pending", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ar-b4o3", - "type": "arrow", - "x": 1628, - "y": 1644, - "width": 124, - "height": 0, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "strokeWidth": 2, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 2713, - "points": [ - [ - 0, - 0 - ], - [ - 124, - 0 - ] - ], - "startArrowhead": null, - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-b4o3-t", - "type": "text" - } - ], - "index": "a18a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-b4o3-t", - "type": "text", - "x": 1640, - "y": 1630, - "width": 100, - "height": 13, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 2714, - "text": "unverified", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-b4o3", - "index": "a18b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "unverified", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "l3-band", - "type": "rectangle", - "x": 20, - "y": 1930, - "width": 2460, - "height": 850, - "strokeColor": "transparent", - "backgroundColor": "#fff6ef", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "lane3" - ], - "seed": 3010, - "index": "a200", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "l3-head", - "type": "text", - "x": 60, - "y": 1958, - "width": 900, - "height": 22, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane3" - ], - "seed": 3011, - "text": "Desktop - where the deep link may not exist", - "fontSize": 17, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a201", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Desktop - where the deep link may not exist", - "lineHeight": 1.2941176470588236 - }, - { - "id": "l3-sub", - "type": "text", - "x": 60, - "y": 1986, - "width": 1300, - "height": 14, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "lane3" - ], - "seed": 3012, - "text": "the fallback is the shipped code flow with both halves opened at once, so nothing depends on a scheme handler that may not be there", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a202", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "the fallback is the shipped code flow with both halves opened at once, so nothing depends on a scheme handler that may not be there", - "lineHeight": 1.2727272727272727 - }, - { - "id": "d1-panel", - "type": "rectangle", - "x": 60, - "y": 2040, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3101, - "boundElements": [ - { - "id": "ar-d1d2", - "type": "arrow" - } - ], - "index": "a210", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d1-back", - "type": "text", - "x": 76, - "y": 2058, - "width": 20, - "height": 14, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3102, - "text": "<", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a211", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "<", - "lineHeight": 1.2727272727272727 - }, - { - "id": "d1-title", - "type": "text", - "x": 80, - "y": 2086, - "width": 260, - "height": 24, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3103, - "text": "Redeem code", - "fontSize": 19, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a212", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Redeem code", - "lineHeight": 1.263157894736842 - }, - { - "id": "d1-body", - "type": "text", - "x": 80, - "y": 2128, - "width": 260, - "height": 13, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3104, - "text": "Paste the code you received.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a213", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paste the code you received.", - "lineHeight": 1.3 - }, - { - "id": "d1-field", - "type": "rectangle", - "x": 76, - "y": 2160, - "width": 268, - "height": 40, - "strokeColor": "transparent", - "backgroundColor": "#eceef1", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3105, - "index": "a214", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d1-fieldt", - "type": "text", - "x": 80, - "y": 2174, - "width": 260, - "height": 13, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3106, - "text": "SB-XXXXX-XXXXX-XXXXX-XXXXX", - "fontSize": 10, - "fontFamily": 3, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a215", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "SB-XXXXX-XXXXX-XXXXX-XXXXX", - "lineHeight": 1.3 - }, - { - "id": "d1-paste", - "type": "text", - "x": 80, - "y": 2216, - "width": 260, - "height": 13, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3107, - "text": "Paste", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a216", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paste", - "lineHeight": 1.3 - }, - { - "id": "d1-scan", - "type": "rectangle", - "x": 126, - "y": 2254, - "width": 168, - "height": 168, - "strokeColor": "#d6dae0", - "backgroundColor": "#f2f3f5", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3108, - "index": "a217", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d1-scant", - "type": "text", - "x": 126, - "y": 2332, - "width": 168, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3109, - "text": "QR scanner", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a218", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "QR scanner", - "lineHeight": 1.3333333333333333 - }, - { - "id": "d1-btn", - "type": "rectangle", - "x": 90, - "y": 2510, - "width": 240, - "height": 38, - "strokeColor": "transparent", - "backgroundColor": "#dcc9b4", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3110, - "boundElements": [ - { - "id": "d1-btnt", - "type": "text" - } - ], - "index": "a219", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d1-btnt", - "type": "text", - "x": 100, - "y": 2522, - "width": 220, - "height": 14, - "strokeColor": "#ffffff", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3111, - "text": "Redeem", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "d1-btn", - "index": "a21a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Redeem", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "d1-note", - "type": "text", - "x": 80, - "y": 2566, - "width": 260, - "height": 24, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3112, - "text": "opened by Buy in browser at the same\nmoment as the browser, and waiting", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a21b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "opened by Buy in browser at the same\nmoment as the browser, and waiting", - "lineHeight": 1.3333333333333333 - }, - { - "id": "d1-capt", - "type": "text", - "x": 60, - "y": 2680, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3113, - "text": "D1. Both halves at once", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a21c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "D1. Both halves at once", - "lineHeight": 1.2727272727272727 - }, - { - "id": "d1-capb", - "type": "text", - "x": 60, - "y": 2700, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D1", - "lane3" - ], - "seed": 3114, - "text": "On desktop, Buy in browser opens the page and this screen\ntogether, so the code has somewhere to go the moment it\nappears. No new screen, no deep link, no A6 - the shipped\nflow, with one extra window. It is also the honest fallback for\nany platform where the scheme turns out not to work.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a21d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "On desktop, Buy in browser opens the page and this screen\ntogether, so the code has somewhere to go the moment it\nappears. No new screen, no deep link, no A6 - the shipped\nflow, with one extra window. It is also the honest fallback for\nany platform where the scheme turns out not to work.", - "lineHeight": 1.4666666666666666 - }, - { - "id": "d2-panel", - "type": "rectangle", - "x": 480, - "y": 2040, - "width": 300, - "height": 620, - "strokeColor": "#8f97a3", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3201, - "boundElements": [ - { - "id": "ar-d1d2", - "type": "arrow" - } - ], - "index": "a220", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-url", - "type": "rectangle", - "x": 480, - "y": 2040, - "width": 300, - "height": 26, - "strokeColor": "transparent", - "backgroundColor": "#eceef1", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3202, - "index": "a221", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-urlt", - "type": "text", - "x": 496, - "y": 2048, - "width": 268, - "height": 12, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3203, - "text": "badges.simplex.chat/#/tier?app=desktop", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a222", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "badges.simplex.chat/#/tier?app=desktop", - "lineHeight": 1.3333333333333333 - }, - { - "id": "d2-title", - "type": "text", - "x": 496, - "y": 2090, - "width": 268, - "height": 22, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3204, - "text": "Paid. Here is your code.", - "fontSize": 16, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a223", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Paid. Here is your code.", - "lineHeight": 1.375 - }, - { - "id": "d2-code", - "type": "rectangle", - "x": 496, - "y": 2128, - "width": 268, - "height": 36, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3205, - "index": "a224", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-codet", - "type": "text", - "x": 500, - "y": 2140, - "width": 260, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3206, - "text": "SB-4K2M9-XQ7VT-H3BNP-R8WLC", - "fontSize": 10, - "fontFamily": 3, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a225", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "SB-4K2M9-XQ7VT-H3BNP-R8WLC", - "lineHeight": 1.4 - }, - { - "id": "d2-btn", - "type": "rectangle", - "x": 496, - "y": 2180, - "width": 268, - "height": 34, - "strokeColor": "#0053d0", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3207, - "boundElements": [ - { - "id": "d2-btnt", - "type": "text" - } - ], - "index": "a226", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-btnt", - "type": "text", - "x": 506, - "y": 2190, - "width": 248, - "height": 14, - "strokeColor": "#0053d0", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3208, - "text": "Copy code", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "d2-btn", - "index": "a227", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "originalText": "Copy code", - "autoResize": true, - "lineHeight": 1.2727272727272727 - }, - { - "id": "d2-qr", - "type": "rectangle", - "x": 496, - "y": 2236, - "width": 110, - "height": 110, - "strokeColor": "#d6dae0", - "backgroundColor": "#f7f8fa", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": null, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3209, - "index": "a228", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-qrt", - "type": "text", - "x": 496, - "y": 2352, - "width": 110, - "height": 24, - "strokeColor": "#9aa1ab", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3210, - "text": "scan to carry it\nto your phone", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "top", - "autoResize": false, - "index": "a229", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "scan to carry it\nto your phone", - "lineHeight": 1.3333333333333333 - }, - { - "id": "d2-redeem", - "type": "text", - "x": 622, - "y": 2236, - "width": 142, - "height": 36, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3211, - "text": "REDEEM IT IN THE APP\nThe Redeem code screen\nis already open.", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a22a", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "REDEEM IT IN THE APP\nThe Redeem code screen\nis already open.", - "lineHeight": 1.411764705882353 - }, - { - "id": "d2-warn", - "type": "rectangle", - "x": 622, - "y": 2288, - "width": 142, - "height": 58, - "strokeColor": "transparent", - "backgroundColor": "#fdf3e7", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3212, - "index": "a22b", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "d2-warnt", - "type": "text", - "x": 632, - "y": 2298, - "width": 122, - "height": 38, - "strokeColor": "#8a5a1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3213, - "text": "This is the only copy.\nSaved in this browser\nand nowhere else.", - "fontSize": 8.5, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a22c", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "This is the only copy.\nSaved in this browser\nand nowhere else.", - "lineHeight": 1.4901960784313726 - }, - { - "id": "d2-capt", - "type": "text", - "x": 480, - "y": 2680, - "width": 300, - "height": 14, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3214, - "text": "D2. The page, told which app it is", - "fontSize": 11, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a22d", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "D2. The page, told which app it is", - "lineHeight": 1.2727272727272727 - }, - { - "id": "d2-capb", - "type": "text", - "x": 480, - "y": 2700, - "width": 340, - "height": 66, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "D2", - "lane3" - ], - "seed": 3215, - "text": "codeIssued as it ships, with one line changed. The app builds\nthe URL, so ?app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets ?app=true and the A5 screen instead.", - "fontSize": 9, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a22e", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "codeIssued as it ships, with one line changed. The app builds\nthe URL, so ?app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets ?app=true and the A5 screen instead.", - "lineHeight": 1.4666666666666666 - }, - { - "id": "q-box", - "type": "rectangle", - "x": 900, - "y": 2040, - "width": 1540, - "height": 260, - "strokeColor": "#e0a672", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "Q", - "lane3" - ], - "seed": 3301, - "index": "a230", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "q-head", - "type": "text", - "x": 924, - "y": 2064, - "width": 900, - "height": 20, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "Q", - "lane3" - ], - "seed": 3302, - "text": "QUESTIONS - deep links, and whether the desktop can have them at all", - "fontSize": 15, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a231", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "QUESTIONS - deep links, and whether the desktop can have them at all", - "lineHeight": 1.3333333333333333 - }, - { - "id": "q-body", - "type": "text", - "x": 924, - "y": 2098, - "width": 1490, - "height": 130, - "strokeColor": "#4b5563", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "Q", - "lane3" - ], - "seed": 3303, - "text": "1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? The attempt is made from a hidden frame, so a refusal stays inside the frame rather than replacing the screen with an error page that would\n put the code into history. Expect nothing to happen on iOS Safari, which has ignored custom schemes in frames for years, nor on Chrome, which blocks a gestureless external-protocol\n launch - and a payment settling minutes after the last tap has no gesture. So Return to SimpleX is the real path. What is left is to try it on devices once the app registers the scheme.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a232", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? The attempt is made from a hidden frame, so a refusal stays inside the frame rather than replacing the screen with an error page that would\n put the code into history. Expect nothing to happen on iOS Safari, which has ignored custom schemes in frames for years, nor on Chrome, which blocks a gestureless external-protocol\n launch - and a payment settling minutes after the last tap has no gesture. So Return to SimpleX is the real path. What is left is to try it on devices once the app registers the scheme.", - "lineHeight": 1.4444444444444444 - }, - { - "id": "n-box", - "type": "rectangle", - "x": 900, - "y": 2340, - "width": 1540, - "height": 300, - "strokeColor": "#c9ced6", - "backgroundColor": "#ffffff", - "fillStyle": "solid", - "strokeWidth": 1, - "roughness": 0, - "roundness": { - "type": 3 - }, - "groupIds": [ - "N", - "lane3" - ], - "seed": 3401, - "index": "a233", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false - }, - { - "id": "n-head", - "type": "text", - "x": 924, - "y": 2364, - "width": 900, - "height": 20, - "strokeColor": "#1c1e26", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "N", - "lane3" - ], - "seed": 3402, - "text": "What has to be built, and what it costs", - "fontSize": 15, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a234", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "What has to be built, and what it costs", - "lineHeight": 1.3333333333333333 - }, - { - "id": "n-body", - "type": "text", - "x": 924, - "y": 2398, - "width": 1490, - "height": 220, - "strokeColor": "#4b5563", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "N", - "lane3" - ], - "seed": 3403, - "text": "The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read the app flag from the fragment and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it;\nand for ?app=desktop, keep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": false, - "index": "a235", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read the app flag from the fragment and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it;\nand for ?app=desktop, keep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.", - "lineHeight": 2 - }, - { - "id": "ar-d1d2", - "type": "arrow", - "x": 368, - "y": 2350, - "width": 104, - "height": 0, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "strokeWidth": 2, - "strokeStyle": "dashed", - "roughness": 0, - "roundness": null, - "elbowed": true, - "groupIds": [], - "seed": 3501, - "points": [ - [ - 0, - 0 - ], - [ - 104, - 0 - ] - ], - "startArrowhead": "arrow", - "endArrowhead": "arrow", - "startBinding": null, - "endBinding": null, - "boundElements": [ - { - "id": "ar-d1d2-t", - "type": "text" - } - ], - "index": "a250", - "version": 2, - "versionNonce": 1589412771, - "isDeleted": false, - "fillStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "updated": 1790251896821, - "link": null, - "locked": false, - "lastCommittedPoint": null - }, - { - "id": "ar-d1d2-t", - "type": "text", - "x": 370.23193359375, - "y": 2337, - "width": 99.5361328125, - "height": 26, - "strokeColor": "#a15c1e", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [], - "seed": 3502, - "text": "opened together, code\ncarried by hand", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "center", - "verticalAlign": "middle", - "containerId": "ar-d1d2", - "index": "a251", - "version": 4, - "versionNonce": 1533799245, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251895408, - "link": null, - "locked": false, - "originalText": "opened together, code carried by hand", - "autoResize": true, - "lineHeight": 1.3 - }, - { - "id": "ft-note", - "type": "text", - "x": 60, - "y": 2830, - "width": 2400, - "height": 26, - "strokeColor": "#6b7280", - "backgroundColor": "transparent", - "roughness": 0, - "groupIds": [ - "footer" - ], - "seed": 4005, - "text": "Copy is quoted from the app and the page. The app's screens take the page's names: Your level becomes Choose your badge, the level name becomes How long?, and Check your order is shared already.\nStore prices are illustrative and come from the storefront; the page's prices are the shipped catalog. Not drawn: the page's refusals - no active price, a provider unavailable, too many attempts - which are unchanged and already built.", - "fontSize": 10, - "fontFamily": 2, - "textAlign": "left", - "verticalAlign": "top", - "autoResize": true, - "index": "a300", - "version": 1, - "versionNonce": 0, - "isDeleted": false, - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "opacity": 100, - "angle": 0, - "frameId": null, - "roundness": null, - "boundElements": [], - "updated": 1790251001522, - "link": null, - "locked": false, - "containerId": null, - "originalText": "Copy is quoted from the app and the page. The app's screens take the page's names: Your level becomes Choose your badge, the level name becomes How long?, and Check your order is shared already.\nStore prices are illustrative and come from the storefront; the page's prices are the shipped catalog. Not drawn: the page's refusals - no active price, a provider unavailable, too many attempts - which are unchanged and already built.", - "lineHeight": 1.3 - } + {"id":"hdr-bg","type":"rectangle","x":0,"y":0,"width":2500,"height":96,"strokeColor":"transparent","backgroundColor":"#1c1e26","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["header"],"index":"a000"}, + {"id":"hdr-title","type":"text","x":40,"y":24,"width":620,"height":28,"strokeColor":"#ffffff","roughness":0,"groupIds":["header"],"text":"Buying a badge - hidden codes","fontSize":22,"fontFamily":2,"index":"a001","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"hdr-sub","type":"text","x":40,"y":58,"width":1500,"height":15,"strokeColor":"#a0a6b0","roughness":0,"groupIds":["header"],"text":"the shipped code purchase, reused whole: the page still mints a code, the deep link carries it, and the buyer never sees it. solid = exists, dashed = does not","fontSize":12,"fontFamily":2,"index":"a002","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"hdr-r1","type":"text","x":2260,"y":26,"width":200,"height":15,"strokeColor":"#a0a6b0","roughness":0,"groupIds":["header"],"text":"in-app checkout","fontSize":12,"fontFamily":2,"textAlign":"right","autoResize":false,"index":"a003","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"hdr-r2","type":"text","x":2260,"y":48,"width":200,"height":14,"strokeColor":"#6b7280","roughness":0,"groupIds":["header"],"text":"wireframes","fontSize":11,"fontFamily":2,"textAlign":"right","autoResize":false,"index":"a004","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"l1-band","type":"rectangle","x":20,"y":150,"width":2460,"height":850,"strokeColor":"transparent","backgroundColor":"#eef5ff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["lane1"],"index":"a010"}, + {"id":"l1-head","type":"text","x":60,"y":178,"width":900,"height":22,"strokeColor":"#0053d0","roughness":0,"groupIds":["lane1"],"text":"Non-store builds - the browser does all of it","fontSize":17,"fontFamily":2,"index":"a011","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"l1-sub","type":"text","x":60,"y":206,"width":1300,"height":14,"strokeColor":"#6b7280","roughness":0,"groupIds":["lane1"],"text":"no catalog API, no invoice API, no in-app checkout: the app opens the page, the page sells a code, the deep link brings it home","fontSize":11,"fontFamily":2,"index":"a012","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a1-panel","type":"rectangle","x":60,"y":260,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A1","lane1"],"boundElements":[{"id":"ar-a1a2","type":"arrow"}],"index":"a020"}, + {"id":"a1-title","type":"text","x":80,"y":306,"width":260,"height":24,"strokeColor":"#0053d0","roughness":0,"groupIds":["A1","lane1"],"text":"Support SimpleX","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a021","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"a1-body","type":"text","x":76,"y":348,"width":268,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A1","lane1"],"text":"Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a022","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a1-info","type":"text","x":80,"y":404,"width":260,"height":13,"strokeColor":"#0053d0","roughness":0,"groupIds":["A1","lane1"],"text":"(i) How badges protect your privacy","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a023","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a1-hero","type":"rectangle","x":132,"y":448,"width":156,"height":156,"strokeColor":"transparent","backgroundColor":"#e8eef7","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A1","lane1"],"index":"a024"}, + {"id":"a1-herot","type":"text","x":132,"y":519,"width":156,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A1","lane1"],"text":"phone-supporter","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a025","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a1-btn","type":"rectangle","x":90,"y":730,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A1","lane1"],"boundElements":[{"id":"a1-btnt","type":"text"}],"index":"a026"}, + {"id":"a1-btnt","type":"text","x":100,"y":742,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A1","lane1"],"text":"Buy in browser","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a1-btn","index":"a027","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a1-sec","type":"text","x":80,"y":786,"width":260,"height":13,"strokeColor":"#0053d0","roughness":0,"groupIds":["A1","lane1"],"text":"Redeem code","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a028","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a1-secn","type":"text","x":80,"y":806,"width":260,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A1","lane1"],"text":"the manual path, for a code from elsewhere","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a029","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a1-capt","type":"text","x":60,"y":900,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A1","lane1"],"text":"A1. BadgesSupportSimplexView","fontSize":11,"fontFamily":2,"index":"a02a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a1-capb","type":"text","x":60,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A1","lane1"],"text":"Get your code becomes the primary button and loses the\nconfirm-in-browser alert - the tap opens the browser. Redeem\nbadge code drops to the text button. The info button stays as\nit is - the file's TODO would swap it for Why SimpleX is built\nonce purchase lands, and it should not.\nchooseLevelButton() is never called in this build.","fontSize":9,"fontFamily":2,"index":"a02b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"a2-panel","type":"rectangle","x":480,"y":260,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"boundElements":[{"id":"ar-a1a2","type":"arrow"},{"id":"ar-a2a3","type":"arrow"}],"index":"a030"}, + {"id":"a2-url","type":"rectangle","x":480,"y":260,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a031"}, + {"id":"a2-urlt","type":"text","x":496,"y":268,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A2","lane1"],"text":"badges.simplex.chat/#/tier?app=true","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a032","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a2-title","type":"text","x":496,"y":312,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A2","lane1"],"text":"Choose your badge","fontSize":17,"fontFamily":2,"autoResize":false,"index":"a033","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a2-lede","type":"text","x":496,"y":342,"width":268,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["A2","lane1"],"text":"Larger files that stay available longer.","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a034","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"a2-c1","type":"rectangle","x":496,"y":378,"width":130,"height":150,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a035"}, + {"id":"a2-c1i","type":"rectangle","x":545,"y":398,"width":32,"height":32,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a036"}, + {"id":"a2-c1t","type":"text","x":504,"y":442,"width":114,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A2","lane1"],"text":"Supporter\n2GB files\n\n$7/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a037","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a2-c2","type":"rectangle","x":634,"y":378,"width":130,"height":150,"strokeColor":"#0053d0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a038"}, + {"id":"a2-c2i","type":"rectangle","x":683,"y":398,"width":32,"height":32,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"index":"a039"}, + {"id":"a2-c2t","type":"text","x":642,"y":442,"width":114,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A2","lane1"],"text":"Legend\n5GB files\n\n$70/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a03a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a2-btn","type":"rectangle","x":496,"y":560,"width":268,"height":36,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A2","lane1"],"boundElements":[{"id":"a2-btnt","type":"text"}],"index":"a03b"}, + {"id":"a2-btnt","type":"text","x":506,"y":571,"width":248,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A2","lane1"],"text":"Continue","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a2-btn","index":"a03c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a2-capt","type":"text","x":480,"y":900,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A2","lane1"],"text":"A2. Where the link lands","fontSize":11,"fontFamily":2,"index":"a03d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a2-capb","type":"text","x":480,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A2","lane1"],"text":"The page's own second screen, quoted as it is. The flag\nin #/tier?app=true skips its Support SimpleX, which the\napp has just shown. NEW on the page: read that flag, start\nhere, and carry it: the session to checkout, then the order.\nThe app builds the URL, so it can also say which kind of\napp it is - better than guessing from the user agent.","fontSize":9,"fontFamily":2,"index":"a03e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"a3-panel","type":"rectangle","x":900,"y":260,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"boundElements":[{"id":"ar-a2a3","type":"arrow"},{"id":"ar-a3a4","type":"arrow"}],"index":"a040"}, + {"id":"a3-url","type":"rectangle","x":900,"y":260,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"index":"a041"}, + {"id":"a3-urlt","type":"text","x":916,"y":268,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A3","lane1"],"text":"badges.simplex.chat/#/tier?app=true","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a042","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a3-title","type":"text","x":916,"y":312,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"How long?","fontSize":17,"fontFamily":2,"autoResize":false,"index":"a043","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a3-lede","type":"text","x":916,"y":342,"width":268,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["A3","lane1"],"text":"Legend - 5GB files, 21 days","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a044","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"a3-c1","type":"rectangle","x":916,"y":378,"width":84,"height":124,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"index":"a045"}, + {"id":"a3-c1t","type":"text","x":920,"y":418,"width":76,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"1 month\n\n$70","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a046","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a3-c2","type":"rectangle","x":1008,"y":378,"width":84,"height":124,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"index":"a047"}, + {"id":"a3-c2t","type":"text","x":1012,"y":412,"width":76,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"3 months\n\n$140\none month free","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a048","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a3-c3","type":"rectangle","x":1100,"y":378,"width":84,"height":124,"strokeColor":"#0053d0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"index":"a049"}, + {"id":"a3-c3t","type":"text","x":1104,"y":412,"width":76,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"12 months\n\n$420\nsave 50%","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a04a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a3-note","type":"text","x":916,"y":520,"width":268,"height":24,"strokeColor":"#6b7280","roughness":0,"groupIds":["A3","lane1"],"text":"Paid once, for the months you choose.\nNo subscription.","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a04b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a3-btn","type":"rectangle","x":916,"y":560,"width":268,"height":36,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A3","lane1"],"boundElements":[{"id":"a3-btnt","type":"text"}],"index":"a04c"}, + {"id":"a3-btnt","type":"text","x":926,"y":571,"width":248,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A3","lane1"],"text":"Continue","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a3-btn","index":"a04d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a3-capt","type":"text","x":900,"y":900,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A3","lane1"],"text":"A3. Unchanged","fontSize":11,"fontFamily":2,"index":"a04e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a3-capb","type":"text","x":900,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A3","lane1"],"text":"The page's own screen, and the source of the names this\nplan adopts in the app: Choose your badge, How long?,\nCheck your order. Prices and offers are the compiled catalog\nthe page already has, so getBadgeCatalog is not needed by\nanyone in this lane.","fontSize":9,"fontFamily":2,"index":"a04f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"a4-panel","type":"rectangle","x":1320,"y":260,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"boundElements":[{"id":"ar-a3a4","type":"arrow"},{"id":"ar-a4a5","type":"arrow"}],"index":"a050"}, + {"id":"a4-url","type":"rectangle","x":1320,"y":260,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"index":"a051"}, + {"id":"a4-urlt","type":"text","x":1336,"y":268,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A4","lane1"],"text":"badges.simplex.chat/?order=8f3a...","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a052","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a4-title","type":"text","x":1336,"y":312,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Check your order","fontSize":17,"fontFamily":2,"autoResize":false,"index":"a053","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a4-rowsL","type":"text","x":1336,"y":352,"width":160,"height":48,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Your badge\nDuration\nLaunch discount: 50% off\nTotal","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a054","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"a4-rowsR","type":"text","x":1508,"y":352,"width":96,"height":48,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Legend\n12 months\n-$420.00\n$420.00","fontSize":9.5,"fontFamily":2,"textAlign":"right","autoResize":false,"index":"a055","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"a4-label","type":"text","x":1336,"y":420,"width":100,"height":12,"strokeColor":"#6b7280","roughness":0,"groupIds":["A4","lane1"],"text":"PAY WITH","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a056","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a4-m1","type":"rectangle","x":1336,"y":438,"width":84,"height":60,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"index":"a057"}, + {"id":"a4-m1t","type":"text","x":1340,"y":460,"width":76,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Bitcoin","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a058","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a4-m2","type":"rectangle","x":1428,"y":438,"width":84,"height":60,"strokeColor":"#0053d0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"index":"a059"}, + {"id":"a4-m2t","type":"text","x":1432,"y":460,"width":76,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Monero","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a05a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a4-m3","type":"rectangle","x":1520,"y":438,"width":84,"height":60,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"index":"a05b"}, + {"id":"a4-m3t","type":"text","x":1524,"y":460,"width":76,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"Card","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a05c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a4-btn","type":"rectangle","x":1336,"y":560,"width":268,"height":36,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A4","lane1"],"boundElements":[{"id":"a4-btnt","type":"text"}],"index":"a05d"}, + {"id":"a4-btnt","type":"text","x":1346,"y":571,"width":248,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A4","lane1"],"text":"Pay $420.00 with Monero","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a4-btn","index":"a05e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a4-capt","type":"text","x":1320,"y":900,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A4","lane1"],"text":"A4. Unchanged, and the pay screens after it","fontSize":11,"fontFamily":2,"index":"a05f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a4-capb","type":"text","x":1320,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A4","lane1"],"text":"The method is picked here and the code is minted here, as\ntoday: the browser generates it and sends only its SHA-256.\nSend 1.482 XMR, the countdown and Payment received follow\nand are unchanged, so they are not drawn. The service needs\nno new API for this lane at all.","fontSize":9,"fontFamily":2,"index":"a05g","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"a5-panel","type":"rectangle","x":1740,"y":260,"width":300,"height":620,"strokeColor":"#0053d0","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["A5","lane1"],"boundElements":[{"id":"ar-a4a5","type":"arrow"},{"id":"ar-a5a6","type":"arrow"}],"index":"a060"}, + {"id":"a5-url","type":"rectangle","x":1740,"y":260,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A5","lane1"],"index":"a061"}, + {"id":"a5-urlt","type":"text","x":1756,"y":268,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A5","lane1"],"text":"badges.simplex.chat/?order=8f3a...","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a062","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a5-title","type":"text","x":1756,"y":312,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A5","lane1"],"text":"Paid","fontSize":17,"fontFamily":2,"autoResize":false,"index":"a063","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a5-lede","type":"text","x":1756,"y":344,"width":268,"height":26,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A5","lane1"],"text":"Opening SimpleX to add your badge.\nIf nothing happens, use the button.","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a064","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"a5-btn","type":"rectangle","x":1756,"y":396,"width":268,"height":38,"strokeColor":"transparent","backgroundColor":"#0053d0","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A5","lane1"],"boundElements":[{"id":"a5-btnt","type":"text"}],"index":"a065"}, + {"id":"a5-btnt","type":"text","x":1766,"y":408,"width":248,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["A5","lane1"],"text":"Return to SimpleX","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"a5-btn","index":"a066","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a5-sec","type":"text","x":1756,"y":452,"width":268,"height":13,"strokeColor":"#0053d0","roughness":0,"groupIds":["A5","lane1"],"text":"Show code","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a067","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a5-link","type":"rectangle","x":1756,"y":496,"width":268,"height":34,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A5","lane1"],"index":"a068"}, + {"id":"a5-linkt","type":"text","x":1762,"y":507,"width":256,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["A5","lane1"],"text":"simplexchat:/badge/code/SB-4K2M9-...","fontSize":9,"fontFamily":3,"textAlign":"center","autoResize":false,"index":"a069","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"a5-note","type":"text","x":1756,"y":544,"width":268,"height":33,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A5","lane1"],"text":"the page tries this on load; the button is\nthe backup, and Show code is the\nbackup to the backup","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a06a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"a5-capt","type":"text","x":1740,"y":900,"width":300,"height":14,"strokeColor":"#0053d0","roughness":0,"groupIds":["A5","lane1"],"text":"A5. New: the code leaves by link, not by eye","fontSize":11,"fontFamily":2,"index":"a06b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a5-capb","type":"text","x":1740,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A5","lane1"],"text":"Replaces codeIssued for a mobile app purchase. The flag is\nnot in the URL here - it rode the session to checkout and is\nstamped on the order. The code is still minted and saved, so\nShow code recovers it if the link fails; a code that could\nnot be saved gets the plain screen instead. simplexchat: is a\nscheme of its own, so a badge link can never be read as a\nconnection link. The attempt is silent where it is blocked.","fontSize":9,"fontFamily":2,"index":"a06c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"a6-panel","type":"rectangle","x":2160,"y":260,"width":300,"height":620,"strokeColor":"#0053d0","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["A6","lane1"],"boundElements":[{"id":"ar-a5a6","type":"arrow"}],"index":"a070"}, + {"id":"a6-title","type":"text","x":2180,"y":306,"width":260,"height":24,"strokeColor":"#0053d0","roughness":0,"groupIds":["A6","lane1"],"text":"Badge is being issued","fontSize":18,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a071","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a6-spin","type":"ellipse","x":2288,"y":380,"width":44,"height":44,"strokeColor":"#0053d0","fillStyle":"solid","strokeWidth":2,"roughness":0,"groupIds":["A6","lane1"],"index":"a072"}, + {"id":"a6-spint","type":"text","x":2180,"y":440,"width":260,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A6","lane1"],"text":"progress","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a073","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"a6-body","type":"text","x":2186,"y":478,"width":248,"height":39,"strokeColor":"#6b7280","roughness":0,"groupIds":["A6","lane1"],"text":"This takes a moment. You do not\nneed to do anything, and you can\nclose the app.","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a074","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"a6-then","type":"rectangle","x":2176,"y":600,"width":268,"height":96,"strokeColor":"#8f97a3","backgroundColor":"#f2f3f5","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A6","lane1"],"index":"a075"}, + {"id":"a6-theni","type":"rectangle","x":2190,"y":612,"width":240,"height":72,"strokeColor":"transparent","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A6","lane1"],"index":"a076"}, + {"id":"a6-theng","type":"rectangle","x":2298,"y":620,"width":24,"height":24,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["A6","lane1"],"index":"a077"}, + {"id":"a6-thent","type":"text","x":2180,"y":650,"width":260,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["A6","lane1"],"text":"Legend","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a078","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"a6-thens","type":"text","x":2180,"y":666,"width":260,"height":11,"strokeColor":"#6b7280","roughness":0,"groupIds":["A6","lane1"],"text":"shown on your profile","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a079","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a6-thenn","type":"text","x":2160,"y":708,"width":300,"height":11,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["A6","lane1"],"text":"then BadgesYourBadgeView, unchanged","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a07a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"a6-capt","type":"text","x":2160,"y":900,"width":300,"height":14,"strokeColor":"#0053d0","roughness":0,"groupIds":["A6","lane1"],"text":"A6. New screen, and the only new app state","fontSize":11,"fontFamily":2,"index":"a07b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"a6-capb","type":"text","x":2160,"y":920,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["A6","lane1"],"text":"The deep link lands here, not on Redeem code: a pasted code\nand a QR scanner make no sense when the code arrived by\nlink. It calls the existing apiRedeemBadgeCode and shows its\nfailures as that screen does. Naming: Redeeming code names\nthe mechanism, Badge is being issued names the outcome -\nand the outcome is what the buyer cares about.","fontSize":9,"fontFamily":2,"index":"a07c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"ar-a1a2","type":"arrow","x":368,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a1a2-t","type":"text"}],"index":"a080","fillStyle":"solid"}, + {"id":"ar-a1a2-t","type":"text","x":365.810546875,"y":563.5,"width":108.37890625,"height":13,"strokeColor":"#0053d0","roughness":0,"text":"Buy in browser - no alert","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a1a2","index":"a081","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-a2a3","type":"arrow","x":788,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a2a3-t","type":"text"}],"index":"a082","fillStyle":"solid"}, + {"id":"ar-a2a3-t","type":"text","x":800,"y":556,"width":80,"height":13,"strokeColor":"#0053d0","roughness":0,"text":"Continue","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a2a3","index":"a083","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-a3a4","type":"arrow","x":1208,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a3a4-t","type":"text"}],"index":"a084","fillStyle":"solid"}, + {"id":"ar-a3a4-t","type":"text","x":1220,"y":556,"width":80,"height":13,"strokeColor":"#0053d0","roughness":0,"text":"Continue","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a3a4","index":"a085","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-a4a5","type":"arrow","x":1628,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a4a5-t","type":"text"}],"index":"a086","fillStyle":"solid"}, + {"id":"ar-a4a5-t","type":"text","x":1640,"y":556,"width":80,"height":13,"strokeColor":"#0053d0","roughness":0,"text":"it settles","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a4a5","index":"a087","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-a5a6","type":"arrow","x":2048,"y":570,"width":104,"height":0,"strokeColor":"#0053d0","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-a5a6-t","type":"text"}],"index":"a088","fillStyle":"solid"}, + {"id":"ar-a5a6-t","type":"text","x":2056.93115234375,"y":557,"width":86.1376953125,"height":26,"strokeColor":"#0053d0","roughness":0,"text":"simplexchat: carries\nthe code","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-a5a6","index":"a089","fillStyle":"solid","strokeWidth":2,"originalText":"simplexchat: carries the code","lineHeight":1.3}, + {"id":"l2-band","type":"rectangle","x":20,"y":1040,"width":2460,"height":850,"strokeColor":"transparent","backgroundColor":"#f5f1ff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["lane2"],"index":"a100"}, + {"id":"l2-head","type":"text","x":60,"y":1068,"width":900,"height":22,"strokeColor":"#5b21b6","roughness":0,"groupIds":["lane2"],"text":"Store builds - the store path in the app, the browser alongside it","fontSize":17,"fontFamily":2,"index":"a101","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"l2-sub","type":"text","x":60,"y":1096,"width":1300,"height":14,"strokeColor":"#6b7280","roughness":0,"groupIds":["lane2"],"text":"Buy in browser runs lane A unchanged; only these four screens and the outcomes after them are in-app, and only here","fontSize":11,"fontFamily":2,"index":"a102","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b1-panel","type":"rectangle","x":60,"y":1150,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B1","lane2"],"boundElements":[{"id":"ar-b1b2","type":"arrow"}],"index":"a110"}, + {"id":"b1-title","type":"text","x":80,"y":1196,"width":260,"height":24,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B1","lane2"],"text":"Support SimpleX","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a111","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b1-body","type":"text","x":76,"y":1238,"width":268,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B1","lane2"],"text":"Get a badge to send larger files (2-5GB)\nthat stay available longer (7-21 days),\nand to show it on your profile.","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a112","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b1-info","type":"text","x":80,"y":1294,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B1","lane2"],"text":"(i) How badges protect your privacy","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a113","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b1-hero","type":"rectangle","x":132,"y":1338,"width":156,"height":156,"strokeColor":"transparent","backgroundColor":"#efeaf9","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B1","lane2"],"index":"a114"}, + {"id":"b1-herot","type":"text","x":132,"y":1409,"width":156,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["B1","lane2"],"text":"phone-supporter","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a115","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"b1-btn","type":"rectangle","x":90,"y":1620,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#5b21b6","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B1","lane2"],"boundElements":[{"id":"b1-btnt","type":"text"}],"index":"a116"}, + {"id":"b1-btnt","type":"text","x":100,"y":1632,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["B1","lane2"],"text":"Choose your badge","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"b1-btn","index":"a117","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b1-sec","type":"text","x":80,"y":1676,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B1","lane2"],"text":"Buy in browser","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a118","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b1-secn","type":"text","x":80,"y":1696,"width":260,"height":24,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["B1","lane2"],"text":"where the browser is not allowed,\nthis slot reads Redeem code instead","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a119","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"b1-capt","type":"text","x":60,"y":1790,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B1","lane2"],"text":"B1. Two buttons, one of them gated","fontSize":11,"fontFamily":2,"index":"a11a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b1-capb","type":"text","x":60,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["B1","lane2"],"text":"Buy in browser is the same tap as A1's and runs the same\nlane, but only where the store rules allow it - a wider set of\nmarkets than the crowdfunding banner covers, and not pinned\ndown yet. Where it is not allowed the slot reads Redeem code\ninstead, so this screen always offers one of the two and codes\nare never out of reach.","fontSize":9,"fontFamily":2,"index":"a11b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"b2-panel","type":"rectangle","x":480,"y":1150,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"boundElements":[{"id":"ar-b1b2","type":"arrow"},{"id":"ar-b2b3","type":"arrow"}],"index":"a120"}, + {"id":"b2-back","type":"text","x":496,"y":1168,"width":20,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B2","lane2"],"text":"<","fontSize":11,"fontFamily":2,"autoResize":false,"index":"a121","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b2-title","type":"text","x":500,"y":1196,"width":260,"height":24,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B2","lane2"],"text":"Choose your badge","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a122","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b2-lede","type":"text","x":500,"y":1236,"width":260,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["B2","lane2"],"text":"Larger files that stay available longer.","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a123","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b2-avatar","type":"ellipse","x":598,"y":1268,"width":56,"height":56,"strokeColor":"transparent","backgroundColor":"#e3e6ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["B2","lane2"],"index":"a124"}, + {"id":"b2-name","type":"text","x":500,"y":1334,"width":260,"height":15,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B2","lane2"],"text":"My nickname [badge] v","fontSize":12,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a125","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"b2-c1","type":"rectangle","x":492,"y":1378,"width":132,"height":150,"strokeColor":"#5b21b6","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"index":"a126"}, + {"id":"b2-c1i","type":"rectangle","x":542,"y":1398,"width":32,"height":32,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"index":"a127"}, + {"id":"b2-c1t","type":"text","x":500,"y":1442,"width":116,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B2","lane2"],"text":"Supporter\nSend 2GB files\n\n$6.99/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a128","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b2-c2","type":"rectangle","x":636,"y":1378,"width":132,"height":150,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"index":"a129"}, + {"id":"b2-c2i","type":"rectangle","x":686,"y":1398,"width":32,"height":32,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"index":"a12a"}, + {"id":"b2-c2t","type":"text","x":644,"y":1442,"width":116,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B2","lane2"],"text":"Legend\nSend 5GB files\n\n$69.99/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a12b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b2-btn","type":"rectangle","x":510,"y":1596,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#5b21b6","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B2","lane2"],"boundElements":[{"id":"b2-btnt","type":"text"}],"index":"a12c"}, + {"id":"b2-btnt","type":"text","x":520,"y":1608,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["B2","lane2"],"text":"Continue","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"b2-btn","index":"a12d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b2-sec","type":"text","x":500,"y":1652,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B2","lane2"],"text":"Redeem code","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a12e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b2-infon","type":"text","x":500,"y":1676,"width":260,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["B2","lane2"],"text":"here only when B1 offers the browser;\nthe privacy link always stays on B1","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a12f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"b2-capt","type":"text","x":480,"y":1790,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B2","lane2"],"text":"B2. BadgesChooseBadgeView","fontSize":11,"fontFamily":2,"index":"a130","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b2-capb","type":"text","x":480,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["B2","lane2"],"text":"Your level becomes Choose your badge, with the page's lede.\nRedeem code sits here only when B1's slot is taken by Buy in\nbrowser; where the browser is not allowed it stays on B1 and\nthis screen has Continue alone. The privacy link never moves -\nit stays on Support SimpleX in both builds.","fontSize":9,"fontFamily":2,"index":"a131","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"b3-panel","type":"rectangle","x":900,"y":1150,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"boundElements":[{"id":"ar-b2b3","type":"arrow"},{"id":"ar-b3b4","type":"arrow"}],"index":"a132"}, + {"id":"b3-back","type":"text","x":916,"y":1168,"width":20,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B3","lane2"],"text":"<","fontSize":11,"fontFamily":2,"autoResize":false,"index":"a133","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b3-title","type":"text","x":920,"y":1196,"width":260,"height":24,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B3","lane2"],"text":"How long?","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a134","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b3-lede","type":"text","x":920,"y":1236,"width":260,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["B3","lane2"],"text":"Legend - 5GB files, 21 days","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a135","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b3-avatar","type":"ellipse","x":1022,"y":1268,"width":56,"height":56,"strokeColor":"transparent","backgroundColor":"#e3e6ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["B3","lane2"],"index":"a136"}, + {"id":"b3-name","type":"text","x":920,"y":1334,"width":260,"height":15,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"My nickname [badge]","fontSize":12,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a137","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"b3-c1","type":"rectangle","x":912,"y":1386,"width":85,"height":124,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"index":"a138"}, + {"id":"b3-c1t","type":"text","x":917,"y":1426,"width":75,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"1 month\n\n$69.99","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a139","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b3-c2","type":"rectangle","x":1007,"y":1386,"width":85,"height":124,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"index":"a13a"}, + {"id":"b3-c2t","type":"text","x":1010,"y":1426,"width":79,"height":39,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"Monthly\n\n$69.99/month","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a13b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b3-c3","type":"rectangle","x":1102,"y":1386,"width":85,"height":124,"strokeColor":"#5b21b6","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":2,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"index":"a13c"}, + {"id":"b3-c3t","type":"text","x":1105,"y":1420,"width":79,"height":52,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"Annual\n\n$699.99/year\nSave 17%","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a13d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"b3-btn","type":"rectangle","x":930,"y":1620,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#5b21b6","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B3","lane2"],"boundElements":[{"id":"b3-btnt","type":"text"}],"index":"a13e"}, + {"id":"b3-btnt","type":"text","x":940,"y":1632,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["B3","lane2"],"text":"Continue","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"b3-btn","index":"a13f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b3-foot","type":"text","x":920,"y":1676,"width":260,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["B3","lane2"],"text":"Renews on 24 August 2027. Cancel anytime.","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a140","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b3-capt","type":"text","x":900,"y":1790,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B3","lane2"],"text":"B3. BadgesHowLongView, disarmed","fontSize":11,"fontFamily":2,"index":"a141","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b3-capb","type":"text","x":900,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["B3","lane2"],"text":"The level name was the title; now How long? is, with the\nchosen badge on the line under it, as the page does it.\nPay $69.99 becomes Continue and purchase() moves to B4.\nBadgePeriod as written today: one-time, monthly, annual,\nprices from StoreKit and Play, which need not match the web\npage's and generally will not.","fontSize":9,"fontFamily":2,"index":"a142","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"b4-panel","type":"rectangle","x":1320,"y":1150,"width":300,"height":620,"strokeColor":"#5b21b6","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["B4","lane2"],"boundElements":[{"id":"ar-b3b4","type":"arrow"},{"id":"ar-b4o1","type":"arrow"},{"id":"ar-b4o2","type":"arrow"},{"id":"ar-b4o3","type":"arrow"}],"index":"a143"}, + {"id":"b4-back","type":"text","x":1336,"y":1168,"width":20,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B4","lane2"],"text":"<","fontSize":11,"fontFamily":2,"autoResize":false,"index":"a144","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b4-title","type":"text","x":1340,"y":1196,"width":260,"height":24,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B4","lane2"],"text":"Check your order","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a145","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b4-rowsL","type":"text","x":1344,"y":1270,"width":160,"height":36,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B4","lane2"],"text":"Your badge\nDuration\nTotal","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a146","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b4-rowsR","type":"text","x":1500,"y":1270,"width":96,"height":36,"strokeColor":"#1c1e26","roughness":0,"groupIds":["B4","lane2"],"text":"Legend\nAnnual\n$699.99/year","fontSize":9.5,"fontFamily":2,"textAlign":"right","autoResize":false,"index":"a147","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"b4-div","type":"rectangle","x":1344,"y":1292,"width":252,"height":1,"strokeColor":"#dfe4ea","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["B4","lane2"],"index":"a148"}, + {"id":"b4-note","type":"text","x":1344,"y":1336,"width":252,"height":26,"strokeColor":"#6b7280","roughness":0,"groupIds":["B4","lane2"],"text":"Charged by the App Store to the\naccount on this device.","fontSize":9.5,"fontFamily":2,"autoResize":false,"index":"a149","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b4-btn","type":"rectangle","x":1350,"y":1620,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#5b21b6","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["B4","lane2"],"boundElements":[{"id":"b4-btnt","type":"text"}],"index":"a14a"}, + {"id":"b4-btnt","type":"text","x":1360,"y":1632,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["B4","lane2"],"text":"Pay $699.99/year","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"b4-btn","index":"a14b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b4-foot","type":"text","x":1340,"y":1676,"width":260,"height":13,"strokeColor":"#6b7280","roughness":0,"groupIds":["B4","lane2"],"text":"Renews on 24 August 2027. Cancel anytime.","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a14c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"b4-capt","type":"text","x":1320,"y":1790,"width":300,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["B4","lane2"],"text":"B4. New screen, one for every market","fontSize":11,"fontFamily":2,"index":"a14d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"b4-capb","type":"text","x":1320,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["B4","lane2"],"text":"No second button and nothing about paying elsewhere - that\nchoice was made back on B1. So this screen is identical in\nevery storefront and nothing on it is gated, which also means\nthe subscription never has to be reconciled against a web\nprice.","fontSize":9,"fontFamily":2,"index":"a14e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"o1-card","type":"rectangle","x":1740,"y":1160,"width":300,"height":120,"strokeColor":"#5b21b6","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["O1","lane2"],"boundElements":[{"id":"ar-b4o1","type":"arrow"}],"index":"a150"}, + {"id":"o1-t","type":"text","x":1760,"y":1174,"width":260,"height":17,"strokeColor":"#1c1e26","roughness":0,"groupIds":["O1","lane2"],"text":"Paid","fontSize":13,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a151","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3076923076923077}, + {"id":"o1-s","type":"text","x":1760,"y":1198,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["O1","lane2"],"text":"o Issuing your badge","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a152","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"o1-b","type":"text","x":1766,"y":1222,"width":248,"height":26,"strokeColor":"#6b7280","roughness":0,"groupIds":["O1","lane2"],"text":"The store has taken the payment.\nKeeps trying if you close the app.","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a153","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"o2-card","type":"rectangle","x":1740,"y":1300,"width":300,"height":96,"strokeColor":"#8f97a3","backgroundColor":"#f2f3f5","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["O2","lane2"],"index":"a154"}, + {"id":"o2-i","type":"rectangle","x":1756,"y":1312,"width":268,"height":72,"strokeColor":"transparent","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["O2","lane2"],"index":"a155"}, + {"id":"o2-g","type":"rectangle","x":1878,"y":1320,"width":24,"height":24,"strokeColor":"transparent","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["O2","lane2"],"index":"a156"}, + {"id":"o2-t","type":"text","x":1760,"y":1350,"width":260,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["O2","lane2"],"text":"Legend","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a157","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"o2-s","type":"text","x":1760,"y":1366,"width":260,"height":11,"strokeColor":"#6b7280","roughness":0,"groupIds":["O2","lane2"],"text":"shown on your profile","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a158","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"o2-n","type":"text","x":1740,"y":1402,"width":300,"height":11,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["O2","lane2"],"text":"BadgesYourBadgeView, unchanged","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a159","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"o3-card","type":"rectangle","x":1740,"y":1424,"width":300,"height":130,"strokeColor":"#5b21b6","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["O3","lane2"],"boundElements":[{"id":"ar-b4o2","type":"arrow"}],"index":"a15a"}, + {"id":"o3-t","type":"text","x":1760,"y":1438,"width":260,"height":15,"strokeColor":"#1c1e26","roughness":0,"groupIds":["O3","lane2"],"text":"Waiting for approval","fontSize":12,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.25}, + {"id":"o3-s","type":"text","x":1760,"y":1460,"width":260,"height":13,"strokeColor":"#5b21b6","roughness":0,"groupIds":["O3","lane2"],"text":"o Nothing has been charged","fontSize":9.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.368421052631579}, + {"id":"o3-b","type":"text","x":1746,"y":1484,"width":288,"height":39,"strokeColor":"#6b7280","roughness":0,"groupIds":["O3","lane2"],"text":"Ask to Buy on iOS, a slow payment on\nPlay. Holds the slot so nothing can be\nbought twice, and becomes Paid above.","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"o4-card","type":"rectangle","x":1760,"y":1584,"width":260,"height":120,"strokeColor":"#d6dae0","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["O4","lane2"],"boundElements":[{"id":"ar-b4o3","type":"arrow"}],"index":"a15e"}, + {"id":"o4-t","type":"text","x":1776,"y":1598,"width":228,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["O4","lane2"],"text":"Cannot verify this purchase","fontSize":11,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15f","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"o4-b","type":"text","x":1776,"y":1620,"width":228,"height":39,"strokeColor":"#4b5563","roughness":0,"groupIds":["O4","lane2"],"text":"The store returned a transaction\nthat Apple has not signed. Nothing\nhas been bought.","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15g","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"o4-d","type":"rectangle","x":1760,"y":1668,"width":260,"height":1,"strokeColor":"#dfe4ea","backgroundColor":"#dfe4ea","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["O4","lane2"],"index":"a15h"}, + {"id":"o4-o","type":"text","x":1760,"y":1678,"width":260,"height":14,"strokeColor":"#0053d0","roughness":0,"groupIds":["O4","lane2"],"text":"Ok","fontSize":11,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15i","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"o-note","type":"text","x":1740,"y":1712,"width":300,"height":44,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["O","lane2"],"text":"iOS only - Play does no local verification, so a bad\ntoken is refused by the service instead. cancelled:\nnothing. other errors: today's Purchase error alert.","fontSize":8.5,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a15j","fillStyle":"solid","strokeWidth":2,"lineHeight":1.7254901960784312}, + {"id":"o-capt","type":"text","x":1740,"y":1790,"width":300,"height":14,"strokeColor":"#5b21b6","roughness":0,"groupIds":["O","lane2"],"text":"B5. What the sheet returns","fontSize":11,"fontFamily":2,"index":"a15k","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"o-capb","type":"text","x":1740,"y":1810,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["O","lane2"],"text":"Two of these are states on BadgeState, not alerts: Paid, which\nthe worker retries, and Waiting, which holds nothing but the\nslot - Support SimpleX must not offer a second purchase of a\nconsumable. Both resolve through the store, not the service:\nTransaction.updates on iOS, queryPurchasesAsync on Play,\nwhich is also how each is re-found after a restart.","fontSize":9,"fontFamily":2,"index":"a15l","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2222222222222223}, + {"id":"ar-b1b2","type":"arrow","x":368,"y":1460,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b1b2-t","type":"text"}],"index":"a180","fillStyle":"solid"}, + {"id":"ar-b1b2-t","type":"text","x":380,"y":1446,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"Choose your badge","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b1b2","index":"a181","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b2b3","type":"arrow","x":788,"y":1460,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b2b3-t","type":"text"}],"index":"a182","fillStyle":"solid"}, + {"id":"ar-b2b3-t","type":"text","x":800,"y":1446,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"Continue","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b2b3","index":"a183","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b3b4","type":"arrow","x":1208,"y":1460,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b3b4-t","type":"text"}],"index":"a184","fillStyle":"solid"}, + {"id":"ar-b3b4-t","type":"text","x":1220,"y":1446,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"Continue","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b3b4","index":"a185","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b4o1","type":"arrow","x":1628,"y":1220,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b4o1-t","type":"text"}],"index":"a186","fillStyle":"solid"}, + {"id":"ar-b4o1-t","type":"text","x":1640,"y":1206,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"verified","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b4o1","index":"a187","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b4o2","type":"arrow","x":1628,"y":1489,"width":104,"height":0,"strokeColor":"#5b21b6","strokeWidth":2,"roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b4o2-t","type":"text"}],"index":"a188","fillStyle":"solid"}, + {"id":"ar-b4o2-t","type":"text","x":1640,"y":1475,"width":80,"height":13,"strokeColor":"#5b21b6","roughness":0,"text":"pending","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b4o2","index":"a189","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"ar-b4o3","type":"arrow","x":1628,"y":1644,"width":124,"height":0,"strokeColor":"#9aa1ab","strokeWidth":2,"strokeStyle":"dashed","roughness":0,"elbowed":true,"points":[[0,0],[124,0]],"endArrowhead":"arrow","boundElements":[{"id":"ar-b4o3-t","type":"text"}],"index":"a18a","fillStyle":"solid"}, + {"id":"ar-b4o3-t","type":"text","x":1640,"y":1630,"width":100,"height":13,"strokeColor":"#6b7280","roughness":0,"text":"unverified","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-b4o3","index":"a18b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"l3-band","type":"rectangle","x":20,"y":1930,"width":2460,"height":850,"strokeColor":"transparent","backgroundColor":"#fff6ef","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["lane3"],"index":"a200"}, + {"id":"l3-head","type":"text","x":60,"y":1958,"width":900,"height":22,"strokeColor":"#a15c1e","roughness":0,"groupIds":["lane3"],"text":"Desktop - where the deep link may not exist","fontSize":17,"fontFamily":2,"index":"a201","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2941176470588236}, + {"id":"l3-sub","type":"text","x":60,"y":1986,"width":1300,"height":14,"strokeColor":"#6b7280","roughness":0,"groupIds":["lane3"],"text":"the fallback is the shipped code flow with both halves opened at once, so nothing depends on a scheme handler that may not be there","fontSize":11,"fontFamily":2,"index":"a202","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d1-panel","type":"rectangle","x":60,"y":2040,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D1","lane3"],"boundElements":[{"id":"ar-d1d2","type":"arrow"}],"index":"a210"}, + {"id":"d1-back","type":"text","x":76,"y":2058,"width":20,"height":14,"strokeColor":"#a15c1e","roughness":0,"groupIds":["D1","lane3"],"text":"<","fontSize":11,"fontFamily":2,"autoResize":false,"index":"a211","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d1-title","type":"text","x":80,"y":2086,"width":260,"height":24,"strokeColor":"#a15c1e","roughness":0,"groupIds":["D1","lane3"],"text":"Redeem code","fontSize":19,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a212","fillStyle":"solid","strokeWidth":2,"lineHeight":1.263157894736842}, + {"id":"d1-body","type":"text","x":80,"y":2128,"width":260,"height":13,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D1","lane3"],"text":"Paste the code you received.","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a213","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"d1-field","type":"rectangle","x":76,"y":2160,"width":268,"height":40,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D1","lane3"],"index":"a214"}, + {"id":"d1-fieldt","type":"text","x":80,"y":2174,"width":260,"height":13,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D1","lane3"],"text":"SB-XXXXX-XXXXX-XXXXX-XXXXX","fontSize":10,"fontFamily":3,"textAlign":"center","autoResize":false,"index":"a215","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"d1-paste","type":"text","x":80,"y":2216,"width":260,"height":13,"strokeColor":"#a15c1e","roughness":0,"groupIds":["D1","lane3"],"text":"Paste","fontSize":10,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a216","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3}, + {"id":"d1-scan","type":"rectangle","x":126,"y":2254,"width":168,"height":168,"strokeColor":"#d6dae0","backgroundColor":"#f2f3f5","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D1","lane3"],"index":"a217"}, + {"id":"d1-scant","type":"text","x":126,"y":2332,"width":168,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D1","lane3"],"text":"QR scanner","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a218","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"d1-btn","type":"rectangle","x":90,"y":2510,"width":240,"height":38,"strokeColor":"transparent","backgroundColor":"#dcc9b4","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D1","lane3"],"boundElements":[{"id":"d1-btnt","type":"text"}],"index":"a219"}, + {"id":"d1-btnt","type":"text","x":100,"y":2522,"width":220,"height":14,"strokeColor":"#ffffff","roughness":0,"groupIds":["D1","lane3"],"text":"Redeem","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"d1-btn","index":"a21a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d1-note","type":"text","x":80,"y":2566,"width":260,"height":24,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D1","lane3"],"text":"opened by Buy in browser at the same\nmoment as the browser, and waiting","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a21b","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"d1-capt","type":"text","x":60,"y":2680,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D1","lane3"],"text":"D1. Both halves at once","fontSize":11,"fontFamily":2,"index":"a21c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d1-capb","type":"text","x":60,"y":2700,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["D1","lane3"],"text":"On desktop, Buy in browser opens the page and this screen\ntogether, so the code has somewhere to go the moment it\nappears. No new screen, no deep link, no A6 - the shipped\nflow, with one extra window. It is also the honest fallback for\nany platform where the scheme turns out not to work.","fontSize":9,"fontFamily":2,"index":"a21d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"d2-panel","type":"rectangle","x":480,"y":2040,"width":300,"height":620,"strokeColor":"#8f97a3","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"boundElements":[{"id":"ar-d1d2","type":"arrow"}],"index":"a220"}, + {"id":"d2-url","type":"rectangle","x":480,"y":2040,"width":300,"height":26,"strokeColor":"transparent","backgroundColor":"#eceef1","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"index":"a221"}, + {"id":"d2-urlt","type":"text","x":496,"y":2048,"width":268,"height":12,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D2","lane3"],"text":"badges.simplex.chat/#/tier?app=desktop","fontSize":9,"fontFamily":2,"autoResize":false,"index":"a222","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"d2-title","type":"text","x":496,"y":2090,"width":268,"height":22,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D2","lane3"],"text":"Paid. Here is your code.","fontSize":16,"fontFamily":2,"autoResize":false,"index":"a223","fillStyle":"solid","strokeWidth":2,"lineHeight":1.375}, + {"id":"d2-code","type":"rectangle","x":496,"y":2128,"width":268,"height":36,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"index":"a224"}, + {"id":"d2-codet","type":"text","x":500,"y":2140,"width":260,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D2","lane3"],"text":"SB-4K2M9-XQ7VT-H3BNP-R8WLC","fontSize":10,"fontFamily":3,"textAlign":"center","autoResize":false,"index":"a225","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4}, + {"id":"d2-btn","type":"rectangle","x":496,"y":2180,"width":268,"height":34,"strokeColor":"#0053d0","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"boundElements":[{"id":"d2-btnt","type":"text"}],"index":"a226"}, + {"id":"d2-btnt","type":"text","x":506,"y":2190,"width":248,"height":14,"strokeColor":"#0053d0","roughness":0,"groupIds":["D2","lane3"],"text":"Copy code","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"d2-btn","index":"a227","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d2-qr","type":"rectangle","x":496,"y":2236,"width":110,"height":110,"strokeColor":"#d6dae0","backgroundColor":"#f7f8fa","fillStyle":"solid","strokeWidth":1,"roughness":0,"groupIds":["D2","lane3"],"index":"a228"}, + {"id":"d2-qrt","type":"text","x":496,"y":2352,"width":110,"height":24,"strokeColor":"#9aa1ab","roughness":0,"groupIds":["D2","lane3"],"text":"scan to carry it\nto your phone","fontSize":9,"fontFamily":2,"textAlign":"center","autoResize":false,"index":"a229","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"d2-redeem","type":"text","x":622,"y":2236,"width":142,"height":36,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D2","lane3"],"text":"REDEEM IT IN THE APP\nThe Redeem code screen\nis already open.","fontSize":8.5,"fontFamily":2,"autoResize":false,"index":"a22a","fillStyle":"solid","strokeWidth":2,"lineHeight":1.411764705882353}, + {"id":"d2-warn","type":"rectangle","x":622,"y":2288,"width":142,"height":58,"strokeColor":"transparent","backgroundColor":"#fdf3e7","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["D2","lane3"],"index":"a22b"}, + {"id":"d2-warnt","type":"text","x":632,"y":2298,"width":122,"height":38,"strokeColor":"#8a5a1e","roughness":0,"groupIds":["D2","lane3"],"text":"This is the only copy.\nSaved in this browser\nand nowhere else.","fontSize":8.5,"fontFamily":2,"autoResize":false,"index":"a22c","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4901960784313726}, + {"id":"d2-capt","type":"text","x":480,"y":2680,"width":300,"height":14,"strokeColor":"#1c1e26","roughness":0,"groupIds":["D2","lane3"],"text":"D2. The page, told which app it is","fontSize":11,"fontFamily":2,"index":"a22d","fillStyle":"solid","strokeWidth":2,"lineHeight":1.2727272727272727}, + {"id":"d2-capb","type":"text","x":480,"y":2700,"width":340,"height":66,"strokeColor":"#6b7280","roughness":0,"groupIds":["D2","lane3"],"text":"codeIssued as it ships, with one line changed. The app builds\nthe URL, so ?app=desktop is a fact rather than a guess -\nbetter than sniffing the user agent, which cannot tell a\ndesktop browser opened by SimpleX from any other. Mobile\ngets ?app=true and the A5 screen instead.","fontSize":9,"fontFamily":2,"index":"a22e","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4666666666666666}, + {"id":"q-box","type":"rectangle","x":900,"y":2040,"width":1540,"height":260,"strokeColor":"#e0a672","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"dashed","roughness":0,"roundness":{"type":3},"groupIds":["Q","lane3"],"index":"a230"}, + {"id":"q-head","type":"text","x":924,"y":2064,"width":900,"height":20,"strokeColor":"#a15c1e","roughness":0,"groupIds":["Q","lane3"],"text":"QUESTIONS - deep links, and whether the desktop can have them at all","fontSize":15,"fontFamily":2,"autoResize":false,"index":"a231","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"q-body","type":"text","x":924,"y":2098,"width":1490,"height":130,"strokeColor":"#4b5563","roughness":0,"groupIds":["Q","lane3"],"text":"1. Desktop has no scheme handler today. Nothing in the desktop module registers one and nothing handles it: appOpenUrl is wired in commonMain but has no desktop plumbing, and\n desktop/build.gradle.kts declares only Deb, Dmg, Msi and Exe with no protocol entry. Adding one is per-OS work - CFBundleURLTypes plus setOpenURIHandler on macOS, registry keys\n the MSI would have to write on Windows, a .desktop file with x-scheme-handler/simplexchat on Linux - and it would still not cover an unpackaged run. Hence D1: desktop does not wait for it.\n\n2. Will a browser follow it without a tap? The attempt is made from a hidden frame, so a refusal stays inside the frame rather than replacing the screen with an error page that would\n put the code into history. Expect nothing to happen on iOS Safari, which has ignored custom schemes in frames for years, nor on Chrome, which blocks a gestureless external-protocol\n launch - and a payment settling minutes after the last tap has no gesture. So Return to SimpleX is the real path. What is left is to try it on devices once the app registers the scheme.","fontSize":10,"fontFamily":2,"autoResize":false,"index":"a232","fillStyle":"solid","strokeWidth":2,"lineHeight":1.4444444444444444}, + {"id":"n-box","type":"rectangle","x":900,"y":2340,"width":1540,"height":300,"strokeColor":"#c9ced6","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":1,"roughness":0,"roundness":{"type":3},"groupIds":["N","lane3"],"index":"a233"}, + {"id":"n-head","type":"text","x":924,"y":2364,"width":900,"height":20,"strokeColor":"#1c1e26","roughness":0,"groupIds":["N","lane3"],"text":"What has to be built, and what it costs","fontSize":15,"fontFamily":2,"autoResize":false,"index":"a234","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3333333333333333}, + {"id":"n-body","type":"text","x":924,"y":2398,"width":1490,"height":220,"strokeColor":"#4b5563","roughness":0,"groupIds":["N","lane3"],"text":"The browser lane needs nothing from the service: the code purchase runs exactly as it ships, the browser mints the code and sends only its SHA-256, and the only reason the buyer\nnever sees one is that the link carries it instead of the screen. The store lane is the part that does need service work - purchaseBadge has to accept a store receipt and verify it,\nand no provider adapter for that exists today.\n\nThe page needs three things: read the app flag from the fragment and start at Choose your badge; at the end, try the deep link and offer Return to SimpleX with Show code under it;\nand for ?app=desktop, keep showing codeIssued as it does now.\n\nThe app needs: Buy in browser without its confirm alert; a handler for the new link; A6, a spinner around the existing apiRedeemBadgeCode; on desktop, opening Redeem code alongside\nthe browser; and for the store lane, B4's checkout and the two BadgeState outcomes behind B5.\n\nWhat it costs: the app cannot show a price before sending the buyer out, because it has no catalog - Buy in browser is a leap into a page. And the code still exists, so it can be\nintercepted, screenshotted from the backup button, or pasted somewhere else. Hidden is not the same as absent.","fontSize":10,"fontFamily":2,"autoResize":false,"index":"a235","fillStyle":"solid","strokeWidth":2,"lineHeight":2}, + {"id":"ar-d1d2","type":"arrow","x":368,"y":2350,"width":104,"height":0,"strokeColor":"#a15c1e","strokeWidth":2,"strokeStyle":"dashed","roughness":0,"elbowed":true,"points":[[0,0],[104,0]],"startArrowhead":"arrow","endArrowhead":"arrow","boundElements":[{"id":"ar-d1d2-t","type":"text"}],"index":"a250","fillStyle":"solid"}, + {"id":"ar-d1d2-t","type":"text","x":370.23193359375,"y":2337,"width":99.5361328125,"height":26,"strokeColor":"#a15c1e","roughness":0,"text":"opened together, code\ncarried by hand","fontSize":10,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar-d1d2","index":"a251","fillStyle":"solid","strokeWidth":2,"originalText":"opened together, code carried by hand","lineHeight":1.3}, + {"id":"ft-note","type":"text","x":60,"y":2830,"width":2400,"height":26,"strokeColor":"#6b7280","roughness":0,"groupIds":["footer"],"text":"Copy is quoted from the app and the page. The app's screens take the page's names: Your level becomes Choose your badge, the level name becomes How long?, and Check your order is shared already.\nStore prices are illustrative and come from the storefront; the page's prices are the shipped catalog. Not drawn: the page's refusals - no active price, a provider unavailable, too many attempts - which are unchanged and already built.","fontSize":10,"fontFamily":2,"index":"a300","fillStyle":"solid","strokeWidth":2,"lineHeight":1.3} ], - "appState": { - "gridSize": 20, - "gridStep": 5, - "gridModeEnabled": false, - "viewBackgroundColor": "#ffffff" - }, + "appState": {"gridSize": 20, "gridStep": 5, "gridModeEnabled": false, "viewBackgroundColor": "#ffffff"}, "files": {} -} \ No newline at end of file +}