Merge branch 'master' into f/channel-multi-owner

This commit is contained in:
spaced4ndy committed 2026-10-23 10:20:55 +04:00
commit 52adcfe72c
686 files changed
+109616 -4558

No files matched your search

+5 -15
View File
@@ -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') }}
+461
View File
@@ -0,0 +1,461 @@
name: android-emu
on:
workflow_dispatch:
inputs:
claude:
description: Also run the Claude job, which spends the Claude subscription's usage limits
type: boolean
default: false
backend:
description: Also start the test backend
type: boolean
default: false
release:
description: Also test the latest released app, to tell a branch's failure from the runner's
type: boolean
default: false
permissions:
contents: read
actions: read
concurrency:
group: android-emu-${{ github.ref }}
cancel-in-progress: true
env:
GHC_VER: 9.6.3
CABAL_VER: 3.10.2.0
JAVA_VER: 17
CORE_PKG: android-core-arm64-v8a.tar.gz
BACKEND_PKG: desktop-linux-backend.tar.gz
APK: android-foss-arm64-v8a-debug.apk
jobs:
plan:
name: choose jobs, find earlier builds
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
run-claude: ${{ steps.jobs.outputs.claude }}
run-backend: ${{ steps.jobs.outputs.backend }}
run-release: ${{ steps.jobs.outputs.release }}
claude-backend: ${{ steps.scope.outputs.backend }}
core: ${{ steps.names.outputs.core }}
core-run: ${{ steps.core.outputs.run-id }}
backend: ${{ steps.names.outputs.backend }}
backend-run: ${{ steps.backend.outputs.run-id }}
steps:
- name: Choose jobs
id: jobs
env:
CLAUDE: ${{ inputs.claude }}
BACKEND: ${{ inputs.backend }}
RELEASE: ${{ inputs.release }}
run: |
echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT"
echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT"
echo "release=$RELEASE" | tee -a "$GITHUB_OUTPUT"
- name: Checkout code
uses: actions/checkout@v6
- name: Claude scope
id: scope
if: steps.jobs.outputs.claude == 'true'
env:
BACKEND: ${{ steps.jobs.outputs.backend }}
run: |
backend=$(sed -n 's/^backend: *//p' scripts/ci/android/claude-scope)
case "$backend" in
yes | no) echo "backend=$backend" | tee -a "$GITHUB_OUTPUT" ;;
*)
echo "::error::claude-scope must say backend: yes or backend: no"
exit 1
;;
esac
if [ "$backend" = yes ] && [ "$BACKEND" != true ]; then
echo "::error::claude-scope says the Claude session uses the test backend: ask for it too"
exit 1
fi
# artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded;
# the backend has desktop-linux's name and sources, so each workflow reuses the other's
- name: Artifact names
id: names
run: |
echo "core=android-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'flake.nix', 'flake.lock', 'scripts/nix/**', 'scripts/ci/android/build-core.sh') }}" >> "$GITHUB_OUTPUT"
echo "backend=desktop-linux-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/desktop-linux/build-backend.sh') }}" >> "$GITHUB_OUTPUT"
- name: Find core library
id: core
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.core }}"
- name: Find backend
id: backend
if: steps.jobs.outputs.backend == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.backend }}"
backend:
name: test backend for the app
needs: plan
if: needs.plan.outputs.run-backend == 'true' && needs.plan.outputs.backend-run == ''
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Show runner
run: |
lsb_release -ds
nproc
free -h
df -h /
- name: Setup Haskell
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ env.GHC_VER }}
cabal-version: ${{ env.CABAL_VER }}
- name: Install libraries
run: |
sudo apt-get update
sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev
- name: Build backend
run: scripts/ci/desktop-linux/build-backend.sh ~/backend/${{ env.BACKEND_PKG }}
- name: Upload backend
uses: actions/upload-artifact@v7
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/backend/${{ env.BACKEND_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
core:
name: core library for Android arm64
needs: plan
if: needs.plan.outputs.core-run == ''
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
filter: tree:0
- name: Show runner
run: |
lsb_release -ds
nproc
free -h
df -h /
- name: Build core library
run: scripts/ci/android/build-core.sh ~/core/${{ env.CORE_PKG }}
- name: Upload core library
uses: actions/upload-artifact@v7
with:
name: ${{ needs.plan.outputs.core }}
path: ~/core/${{ env.CORE_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
app:
name: Android app in the emulator
needs: [plan, core, backend]
# core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it
if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 75
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
submodules: recursive
- name: Show runner
run: |
lsb_release -ds
nproc
free -h
df -h /
echo "ANDROID_HOME=$ANDROID_HOME"
ls "$ANDROID_HOME/platforms" "$ANDROID_HOME/build-tools" "$ANDROID_HOME/ndk" || true
# the emulator's qemu needs libpulse0 and libegl1; the backend's avatars and checks need the rest
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y libpulse0 libegl1 imagemagick sqlite3 fonts-dejavu-core
dpkg-query -W libpulse0 libegl1 imagemagick sqlite3 fonts-dejavu-core
- name: Download core library
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.core }}
path: ~/core
run-id: ${{ needs.plan.outputs.core-run || github.run_id }}
github-token: ${{ github.token }}
- name: Install core library
id: core
run: scripts/ci/android/install-core.sh ~/core/${{ env.CORE_PKG }}
- name: Download backend
if: needs.plan.outputs.run-backend == 'true'
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/backend
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
github-token: ${{ github.token }}
- name: Start test backend
id: backend
if: needs.plan.outputs.run-backend == 'true'
timeout-minutes: 30
run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
# the app and its tests are still built when the backend fails
- name: Setup Java
if: ${{ !cancelled() && steps.core.outcome == 'success' }}
uses: actions/setup-java@v5
with:
distribution: corretto
java-version: ${{ env.JAVA_VER }}
# the injected ABI builds only arm64-v8a, whose core the job has, and puts the APK under build/intermediates
- name: Build app
id: build
if: ${{ !cancelled() && steps.core.outcome == 'success' }}
working-directory: apps/multiplatform
run: |
./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pandroid.injected.build.abi=arm64-v8a :android:assembleFossDebug
mkdir -p "$RUNNER_TEMP/apk"
cp "android/build/intermediates/apk/foss/debug/$APK" "$RUNNER_TEMP/apk"
ls -la "$RUNNER_TEMP/apk"
unzip -l "$RUNNER_TEMP/apk/$APK" | grep -E ' lib/'
- name: Upload app
if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }}
uses: actions/upload-artifact@v7
with:
name: android-app
path: ${{ runner.temp }}/apk/${{ env.APK }}
compression-level: 0
retention-days: 1
if-no-files-found: error
- name: Install emulator and system image
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: scripts/ci/android/install-sdk.sh "$RUNNER_TEMP/android-sdk"
- name: Boot emulator
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: scripts/ci/android/emulator.sh start "$RUNNER_TEMP/smoke-test"
# the test SMP server's addresses have the runner's 127.0.0.1, which adb reverse forwards from the emulator's
- name: Reach the test backend
id: reach
if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome == 'success' }}
run: scripts/ci/android/emulator.sh reverse 5223
- name: Run smoke test
if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome != 'failure' && steps.reach.outcome != 'failure' }}
env:
BACKEND: ${{ steps.reach.outcome }}
run: |
if [ "$BACKEND" = success ]; then
export NAMES_SERVER=$(cat "$RUNNER_TEMP/backend/names-server")
cases=backend
fi
scripts/ci/android/smoke-test.sh "$RUNNER_TEMP/apk/$APK" "$RUNNER_TEMP/smoke-test" ${cases:-}
- name: Stop test backend
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
- name: Upload smoke test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: android-smoke-test
path: ${{ runner.temp }}/smoke-test
retention-days: 3
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
uses: actions/upload-artifact@v7
with:
name: android-backend-logs
path: ${{ runner.temp }}/backend/*.log
retention-days: 3
if-no-files-found: warn
# Claude Code repeats the smoke test with the ui helper. Each run spends the Claude subscription's usage
# limits, so it runs only when asked for. The app-sim-claude environment's branch rule decides which branches
# may use the token.
claude:
name: Claude tests the Android app
needs: [plan, app]
if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
environment:
name: app-sim-claude
deployment: false
concurrency:
group: android-emu-claude-${{ github.ref }}
cancel-in-progress: true
env:
# the same in all app workflows
CLAUDE_CODE_VERSION: 2.1.300
CLAUDE_MODEL: claude-haiku-5-5
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y libpulse0 libegl1 imagemagick sqlite3 fonts-dejavu-core
dpkg-query -W libpulse0 libegl1 imagemagick sqlite3 fonts-dejavu-core
- name: Download app
uses: actions/download-artifact@v8
with:
name: android-app
path: ${{ runner.temp }}/apk
- name: Download backend
if: needs.plan.outputs.claude-backend == 'yes'
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/backend
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
github-token: ${{ github.token }}
- name: Start test backend
id: backend
if: needs.plan.outputs.claude-backend == 'yes'
timeout-minutes: 30
run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
- name: Install emulator and system image
run: scripts/ci/android/install-sdk.sh "$RUNNER_TEMP/android-sdk"
- name: Boot emulator
run: scripts/ci/android/emulator.sh start "$RUNNER_TEMP/claude-test/screens"
- name: Reach the test backend
if: steps.backend.outcome == 'success'
run: scripts/ci/android/emulator.sh reverse 5223
# ui goes on every shell's PATH, where Claude's Bash tool finds it whatever environment it builds
- name: Install Claude Code and the ui command
run: |
npm install --prefix "$RUNNER_TEMP/claude-code" "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION"
echo "$RUNNER_TEMP/claude-code/node_modules/.bin" >> "$GITHUB_PATH"
printf '#!/bin/sh\nexec %s/scripts/ci/android/ui "$@"\n' "$PWD" | sudo tee /usr/local/bin/ui
sudo chmod +x /usr/local/bin/ui
- name: Start the app
run: scripts/ci/android/smoke-test.sh "$RUNNER_TEMP/apk/$APK" "$RUNNER_TEMP/claude-test/screens" start
# Claude's Bash is limited to single ui commands by the guard hook in claude-test.sh. Claude Code's credential
# scrub stays off: on Linux it forces its whole Bash sandbox (bubblewrap, socat), which ui isn't set up for.
- name: Run Claude
id: claude
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1
run: scripts/ci/desktop-linux/claude-test.sh "$RUNNER_TEMP/claude-test" "$RUNNER_TEMP/claude-transcript.jsonl" scripts/ci/android/claude-test.md
- name: Report Claude usage
if: ${{ !cancelled() && steps.claude.outcome != 'skipped' }}
run: scripts/ci/desktop-linux/claude-usage.py "$RUNNER_TEMP/claude-transcript.jsonl" "$RUNNER_TEMP/claude-usage.json"
- name: Stop test backend
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
- name: Upload Claude test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: android-claude-test
path: |
${{ runner.temp }}/claude-test
${{ runner.temp }}/claude-transcript.jsonl
${{ runner.temp }}/claude-transcript.jsonl.stderr
${{ runner.temp }}/claude-transcript.jsonl.guard.log
${{ runner.temp }}/claude-usage.json
retention-days: 7
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
uses: actions/upload-artifact@v7
with:
name: android-claude-backend-logs
path: ${{ runner.temp }}/backend/*.log
retention-days: 3
if-no-files-found: warn
# the latest release's onboarding, to tell a branch's own failure from the runner's
release:
name: released app in the emulator
needs: plan
if: needs.plan.outputs.run-release == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
filter: tree:0
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y libpulse0 libegl1
dpkg-query -W libpulse0 libegl1
- name: Download released app
run: scripts/ci/android/release-apk.sh "$RUNNER_TEMP/release"
- name: Install emulator and system image
run: scripts/ci/android/install-sdk.sh "$RUNNER_TEMP/android-sdk"
- name: Boot emulator
run: scripts/ci/android/emulator.sh start "$RUNNER_TEMP/smoke-test"
- name: Run smoke test
run: scripts/ci/android/smoke-test.sh "$RUNNER_TEMP/release/simplex-aarch64.apk" "$RUNNER_TEMP/smoke-test" onboarding
- name: Upload smoke test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: android-release-smoke-test
path: ${{ runner.temp }}/smoke-test
retention-days: 3
if-no-files-found: warn
+1018 -131
View File
File diff suppressed because it is too large. Load diff
+465
View File
@@ -0,0 +1,465 @@
name: desktop-linux
on:
workflow_dispatch:
inputs:
claude:
description: Also run the Claude job, which spends the Claude subscription's usage limits
type: boolean
default: false
backend:
description: Also start the test backend
type: boolean
default: false
release:
description: Also test the latest released app, to tell a branch's failure from the runner's
type: boolean
default: false
permissions:
contents: read
actions: read
concurrency:
group: desktop-linux-${{ github.ref }}
cancel-in-progress: true
env:
GHC_VER: 9.6.3
CABAL_VER: 3.10.2.0
JAVA_VER: 17
CORE_PKG: desktop-core-linux-x86_64.tar.gz
BACKEND_PKG: desktop-linux-backend.tar.gz
jobs:
plan:
name: choose jobs, find earlier builds
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
run-claude: ${{ steps.jobs.outputs.claude }}
run-backend: ${{ steps.jobs.outputs.backend }}
run-release: ${{ steps.jobs.outputs.release }}
claude-backend: ${{ steps.scope.outputs.backend }}
core: ${{ steps.names.outputs.core }}
core-run: ${{ steps.core.outputs.run-id }}
backend: ${{ steps.names.outputs.backend }}
backend-run: ${{ steps.backend.outputs.run-id }}
steps:
- name: Choose jobs
id: jobs
env:
CLAUDE: ${{ inputs.claude }}
BACKEND: ${{ inputs.backend }}
RELEASE: ${{ inputs.release }}
run: |
echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT"
echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT"
echo "release=$RELEASE" | tee -a "$GITHUB_OUTPUT"
- name: Checkout code
uses: actions/checkout@v6
- name: Claude scope
id: scope
if: steps.jobs.outputs.claude == 'true'
env:
BACKEND: ${{ steps.jobs.outputs.backend }}
run: |
backend=$(sed -n 's/^backend: *//p' scripts/ci/desktop-linux/claude-scope)
case "$backend" in
yes | no) echo "backend=$backend" | tee -a "$GITHUB_OUTPUT" ;;
*)
echo "::error::claude-scope must say backend: yes or backend: no"
exit 1
;;
esac
if [ "$backend" = yes ] && [ "$BACKEND" != true ]; then
echo "::error::claude-scope says the Claude session uses the test backend: ask for it too"
exit 1
fi
# artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded
- name: Artifact names
id: names
run: |
echo "core=desktop-linux-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'scripts/desktop/build-lib-linux.sh', 'scripts/ci/desktop-linux/build-core.sh') }}" >> "$GITHUB_OUTPUT"
echo "backend=desktop-linux-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/desktop-linux/build-backend.sh') }}" >> "$GITHUB_OUTPUT"
- name: Find core library
id: core
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.core }}"
- name: Find backend
id: backend
if: steps.jobs.outputs.backend == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.backend }}"
backend:
name: test backend for the app
needs: plan
if: needs.plan.outputs.run-backend == 'true' && needs.plan.outputs.backend-run == ''
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Show runner
run: |
lsb_release -ds
nproc
free -h
df -h /
- name: Setup Haskell
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ env.GHC_VER }}
cabal-version: ${{ env.CABAL_VER }}
- name: Install libraries
run: |
sudo apt-get update
sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev
- name: Build backend
run: scripts/ci/desktop-linux/build-backend.sh ~/backend/${{ env.BACKEND_PKG }}
- name: Upload backend
uses: actions/upload-artifact@v7
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/backend/${{ env.BACKEND_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
core:
name: core library for Linux
needs: plan
if: needs.plan.outputs.core-run == ''
runs-on: ubuntu-24.04
timeout-minutes: 150
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Show runner
run: |
lsb_release -ds
nproc
free -h
df -h /
- name: Setup Haskell
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ env.GHC_VER }}
cabal-version: ${{ env.CABAL_VER }}
- name: Install libraries
run: |
sudo apt-get update
sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev patchelf
- name: Build core library
run: scripts/ci/desktop-linux/build-core.sh ~/core/${{ env.CORE_PKG }}
- name: Upload core library
uses: actions/upload-artifact@v7
with:
name: ${{ needs.plan.outputs.core }}
path: ~/core/${{ env.CORE_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
app:
name: desktop app under Xvfb
needs: [plan, core, backend]
# core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it
if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
submodules: recursive
- name: Show runner
run: |
lsb_release -ds
nproc
free -h
df -h /
echo "ANDROID_HOME=$ANDROID_HOME"
ls "$ANDROID_HOME/platforms" "$ANDROID_HOME/build-tools" || true
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
- name: Download core library
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.core }}
path: ~/core
run-id: ${{ needs.plan.outputs.core-run || github.run_id }}
github-token: ${{ github.token }}
- name: Install core library
id: core
run: scripts/ci/desktop-linux/install-core.sh ~/core/${{ env.CORE_PKG }}
- name: Download backend
if: needs.plan.outputs.run-backend == 'true'
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/backend
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
github-token: ${{ github.token }}
- name: Start test backend
id: backend
if: needs.plan.outputs.run-backend == 'true'
timeout-minutes: 30
run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
# the app and its tests are still built when the backend fails
- name: Setup Java
if: ${{ !cancelled() && steps.core.outcome == 'success' }}
uses: actions/setup-java@v5
with:
distribution: corretto
java-version: ${{ env.JAVA_VER }}
- name: Build app
id: build
if: ${{ !cancelled() && steps.core.outcome == 'success' }}
working-directory: apps/multiplatform
run: |
./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :desktop:createDistributable
app=release/main/app/simplex
ls -la "$app/bin" "$app/lib/app/resources"
du -sh "$app"
LD_LIBRARY_PATH="$app/lib/app/resources" ldd "$app/lib/app/resources/libapp-lib.so"
# for the Claude job; a tarball keeps the app's file modes, which an artifact drops
- name: Pack app
if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }}
run: tar -czf "$RUNNER_TEMP/desktop-app.tar.gz" -C apps/multiplatform/release/main/app simplex
- name: Upload app
if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }}
uses: actions/upload-artifact@v7
with:
name: desktop-app
path: ${{ runner.temp }}/desktop-app.tar.gz
compression-level: 0
retention-days: 1
if-no-files-found: error
- name: Run desktop tests
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
working-directory: apps/multiplatform
run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :common:desktopTest
- name: Report desktop tests
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: scripts/ci/desktop-linux/test-summary.py apps/multiplatform/common/build/test-results/desktopTest
- name: Run smoke test
if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome != 'failure' }}
env:
BACKEND: ${{ steps.backend.outcome }}
run: |
if [ "$BACKEND" = success ]; then
export NAMES_SERVER=$(cat "$RUNNER_TEMP/backend/names-server")
cases=backend
fi
scripts/ci/desktop-linux/smoke-test.sh apps/multiplatform/release/main/app/simplex "$RUNNER_TEMP/smoke-test" ${cases:-}
- name: Stop test backend
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
- name: Upload smoke test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: desktop-smoke-test
path: ${{ runner.temp }}/smoke-test
retention-days: 3
if-no-files-found: warn
- name: Upload desktop test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: desktop-test-report
path: |
apps/multiplatform/common/build/reports/tests/desktopTest
apps/multiplatform/common/build/test-results/desktopTest
retention-days: 3
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
uses: actions/upload-artifact@v7
with:
name: desktop-backend-logs
path: ${{ runner.temp }}/backend/*.log
retention-days: 3
if-no-files-found: warn
# Claude Code repeats the smoke test with the ui helper. Each run spends the Claude subscription's usage
# limits, so it runs only when asked for. The app-sim-claude environment's branch rule decides which branches
# may use the token.
claude:
name: Claude tests the desktop app
needs: [plan, app]
if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
environment:
name: app-sim-claude
deployment: false
concurrency:
group: desktop-linux-claude-${{ github.ref }}
cancel-in-progress: true
env:
# the same in both app workflows
CLAUDE_CODE_VERSION: 2.1.300
CLAUDE_MODEL: claude-haiku-5-5
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core
- name: Download app
uses: actions/download-artifact@v8
with:
name: desktop-app
path: ${{ runner.temp }}
- name: Unpack app
run: |
mkdir -p "$RUNNER_TEMP/app"
tar -xzf "$RUNNER_TEMP/desktop-app.tar.gz" -C "$RUNNER_TEMP/app"
- name: Download backend
if: needs.plan.outputs.claude-backend == 'yes'
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/backend
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
github-token: ${{ github.token }}
- name: Start test backend
id: backend
if: needs.plan.outputs.claude-backend == 'yes'
timeout-minutes: 30
run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
# ui goes on every shell's PATH, where Claude's Bash tool finds it whatever environment it builds
- name: Install Claude Code and the ui command
run: |
npm install --prefix "$RUNNER_TEMP/claude-code" "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION"
echo "$RUNNER_TEMP/claude-code/node_modules/.bin" >> "$GITHUB_PATH"
printf '#!/bin/sh\nexec %s/scripts/ci/desktop-linux/ui "$@"\n' "$PWD" | sudo tee /usr/local/bin/ui
sudo chmod +x /usr/local/bin/ui
- name: Start the app
run: scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/app/simplex" "$RUNNER_TEMP/claude-test/screens" start
# Claude's Bash is limited to single ui commands by the guard hook in claude-test.sh. Claude Code's credential
# scrub stays off: on Linux it forces its whole Bash sandbox (bubblewrap, socat), which ui isn't set up for.
- name: Run Claude
id: claude
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1
run: scripts/ci/desktop-linux/claude-test.sh "$RUNNER_TEMP/claude-test" "$RUNNER_TEMP/claude-transcript.jsonl"
- name: Report Claude usage
if: ${{ !cancelled() && steps.claude.outcome != 'skipped' }}
run: scripts/ci/desktop-linux/claude-usage.py "$RUNNER_TEMP/claude-transcript.jsonl" "$RUNNER_TEMP/claude-usage.json"
- name: Stop test backend
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend"
- name: Upload Claude test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: desktop-claude-test
path: |
${{ runner.temp }}/claude-test
${{ runner.temp }}/claude-transcript.jsonl
${{ runner.temp }}/claude-transcript.jsonl.stderr
${{ runner.temp }}/claude-transcript.jsonl.guard.log
${{ runner.temp }}/claude-usage.json
retention-days: 7
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
uses: actions/upload-artifact@v7
with:
name: desktop-claude-backend-logs
path: ${{ runner.temp }}/backend/*.log
retention-days: 3
if-no-files-found: warn
# the latest release's onboarding, to tell a branch's own failure from the runner's
release:
name: released app under Xvfb
needs: plan
if: needs.plan.outputs.run-release == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
filter: tree:0
- name: Download released app
run: scripts/ci/desktop-linux/release-app.sh "$RUNNER_TEMP/release"
- name: Install UI tools
run: |
sudo apt-get update
sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr
dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr
- name: Run smoke test
run: scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/release/app/opt/simplex" "$RUNNER_TEMP/smoke-test" onboarding
- name: Upload smoke test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: desktop-release-smoke-test
path: ${{ runner.temp }}/smoke-test
retention-days: 3
if-no-files-found: warn
+397
View File
@@ -0,0 +1,397 @@
name: ios-sim
on:
workflow_dispatch:
inputs:
claude:
description: Also run the Claude session
type: boolean
default: false
backend:
description: Start the test backend
type: boolean
default: false
permissions:
contents: read
actions: read
concurrency:
group: ios-sim-${{ github.ref }}
cancel-in-progress: true
env:
GHC_VER: 9.6.3
CABAL_VER: 3.10.2.0
CORE_PKG: pkg-ios-aarch64-swift-json.zip
BACKEND_PKG: ios-sim-backend.zip
HOMEBREW_NO_AUTO_UPDATE: 1
HOMEBREW_NO_INSTALL_CLEANUP: 1
# newer images add Command Line Tools whose macOS SDK the default Xcode's linker cannot read
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
jobs:
plan:
name: choose jobs, find earlier builds
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
run-claude: ${{ steps.jobs.outputs.claude }}
run-backend: ${{ steps.jobs.outputs.backend }}
claude-backend: ${{ steps.scope.outputs.backend }}
core: ${{ steps.names.outputs.core }}
core-run: ${{ steps.core.outputs.run-id }}
backend: ${{ steps.names.outputs.backend }}
backend-run: ${{ steps.backend.outputs.run-id }}
steps:
- name: Choose jobs
id: jobs
env:
CLAUDE: ${{ inputs.claude }}
BACKEND: ${{ inputs.backend }}
run: |
echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT"
echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT"
- name: Checkout code
uses: actions/checkout@v6
- name: Claude scope
id: scope
if: steps.jobs.outputs.claude == 'true'
env:
BACKEND: ${{ steps.jobs.outputs.backend }}
run: |
backend=$(sed -n 's/^backend: *//p' scripts/ci/ios-sim/claude-scope)
case "$backend" in
yes | no) echo "backend=$backend" | tee -a "$GITHUB_OUTPUT" ;;
*)
echo "::error::claude-scope must say backend: yes or backend: no"
exit 1
;;
esac
if [ "$backend" = yes ] && [ "$BACKEND" != true ]; then
echo "::error::claude-scope says the Claude sessions use the test backend: ask for it too"
exit 1
fi
# artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded
- name: Artifact names
id: names
run: |
echo "core=ios-sim-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-core.sh') }}" >> "$GITHUB_OUTPUT"
echo "backend=ios-sim-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-backend.sh') }}" >> "$GITHUB_OUTPUT"
- name: Find core library
id: core
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.core }}"
- name: Find backend
id: backend
if: steps.jobs.outputs.backend == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.backend }}"
core:
name: core library for the iOS simulator
needs: plan
if: needs.plan.outputs.core-run == ''
runs-on: macos-26
timeout-minutes: 240
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Show runner
run: |
sw_vers
sysctl -n hw.ncpu hw.memsize
df -h /
xcodebuild -version
xcrun --show-sdk-path
ls /Library/Developer/CommandLineTools/SDKs || true
- name: Use the macOS 26 SDK
run: scripts/ci/ios-sim/use-macos26-sdk.sh
- name: Setup Haskell
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ env.GHC_VER }}
cabal-version: ${{ env.CABAL_VER }}
- name: Install libraries
run: brew install openssl@3 libffi gmp
- name: Build core library
id: build
run: scripts/ci/ios-sim/build-core.sh ~/Downloads/${{ env.CORE_PKG }}
- name: Patch core library for the simulator
run: scripts/ci/ios-sim/prepare-libs.sh
- name: Upload core library
uses: actions/upload-artifact@v7
with:
name: ${{ needs.plan.outputs.core }}
path: ~/Downloads/${{ env.CORE_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
backend:
name: test backend for the app
needs: plan
if: needs.plan.outputs.run-backend == 'true' && needs.plan.outputs.backend-run == ''
runs-on: macos-26
timeout-minutes: 240
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Show runner
run: |
sw_vers
xcodebuild -version
xcrun --show-sdk-path
ls /Library/Developer/CommandLineTools/SDKs || true
- name: Use the macOS 26 SDK
run: scripts/ci/ios-sim/use-macos26-sdk.sh
- name: Setup Haskell
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ env.GHC_VER }}
cabal-version: ${{ env.CABAL_VER }}
- name: Build backend
run: scripts/ci/ios-sim/build-backend.sh ~/Downloads/${{ env.BACKEND_PKG }}
- name: Upload backend
uses: actions/upload-artifact@v7
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/Downloads/${{ env.BACKEND_PKG }}
compression-level: 0
retention-days: 30
if-no-files-found: error
app:
name: app in the iOS simulator
needs: [plan, core, backend]
# core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it
if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }}
runs-on: macos-26
timeout-minutes: 120
env:
START_BACKEND: ${{ needs.plan.outputs.run-backend }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Show runner
run: |
sw_vers
df -h /
xcodebuild -version
xcrun --show-sdk-path
ls /Library/Developer/CommandLineTools/SDKs || true
xcrun simctl list runtimes
- name: Use the macOS 26 SDK
run: scripts/ci/ios-sim/use-macos26-sdk.sh
- name: Download core library
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.core }}
path: ~/Downloads
run-id: ${{ needs.plan.outputs.core-run || github.run_id }}
github-token: ${{ github.token }}
- name: Prepare libraries
run: |
scripts/ci/ios-sim/prepare-libs.sh
scripts/ios/update-pbxproj.sh
- name: Download backend
if: env.START_BACKEND == 'true'
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/Downloads
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
github-token: ${{ github.token }}
- name: Start test backend
if: env.START_BACKEND == 'true'
timeout-minutes: 30
run: scripts/ci/ios-sim/backend.sh start ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
- name: Build app
run: |
xcodebuild build -quiet \
-project apps/ios/SimpleX.xcodeproj \
-scheme "SimpleX (iOS)" \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath "$RUNNER_TEMP/DerivedData" \
-onlyUsePackageVersionsFromResolvedFile \
-skipPackagePluginValidation \
-skipMacroValidation \
ARCHS=arm64 \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY=- \
DEVELOPMENT_TEAM= \
PROVISIONING_PROFILE_SPECIFIER=
# for the Claude job; a tarball keeps the app's file modes, which an artifact drops
- name: Pack app
if: needs.plan.outputs.run-claude == 'true'
run: tar -czf "$RUNNER_TEMP/SimpleX.app.tar.gz" -C "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator" SimpleX.app
- name: Upload app
if: needs.plan.outputs.run-claude == 'true'
uses: actions/upload-artifact@v7
with:
name: ios-sim-app
path: ${{ runner.temp }}/SimpleX.app.tar.gz
compression-level: 0
retention-days: 1
if-no-files-found: error
- name: Install AXe
run: scripts/ci/ios-sim/install-axe.sh
- name: Run smoke test
run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator/SimpleX.app" "$RUNNER_TEMP/smoke-test"
- name: Stop test backend
if: ${{ !cancelled() && env.START_BACKEND == 'true' }}
run: scripts/ci/ios-sim/backend.sh stop "$RUNNER_TEMP/backend"
- name: Upload smoke test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: ios-sim-smoke-test
path: ${{ runner.temp }}/smoke-test
retention-days: 3
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && env.START_BACKEND == 'true' }}
uses: actions/upload-artifact@v7
with:
name: ios-sim-backend-logs
path: |
${{ runner.temp }}/backend/*.log
${{ runner.temp }}/backend/seed.md
retention-days: 3
if-no-files-found: warn
# The app-sim-claude environment's branch rule decides which branches may use the token.
claude:
name: Claude tests the iOS app
needs: [plan, app]
if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }}
runs-on: macos-26
timeout-minutes: 330
environment:
name: app-sim-claude
deployment: false
concurrency:
group: ios-sim-claude-${{ github.ref }}
cancel-in-progress: true
env:
# the same in both app workflows
CLAUDE_CODE_VERSION: 2.1.300
steps:
# the job holds the Claude token, so the checkout's GitHub token is not left in .git/config
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Show runner
run: |
sw_vers
xcodebuild -version
xcrun simctl list runtimes
- name: Use the macOS 26 SDK
run: scripts/ci/ios-sim/use-macos26-sdk.sh
- name: Download app
uses: actions/download-artifact@v8
with:
name: ios-sim-app
path: ${{ runner.temp }}
- name: Unpack app
run: tar -xzf "$RUNNER_TEMP/SimpleX.app.tar.gz" -C "$RUNNER_TEMP"
- name: Download backend
if: needs.plan.outputs.claude-backend == 'yes'
uses: actions/download-artifact@v8
with:
name: ${{ needs.plan.outputs.backend }}
path: ~/Downloads
run-id: ${{ needs.plan.outputs.backend-run || github.run_id }}
github-token: ${{ github.token }}
- name: Start test backend
id: backend
if: needs.plan.outputs.claude-backend == 'yes'
timeout-minutes: 30
run: scripts/ci/ios-sim/backend.sh start ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend"
- name: Install AXe
run: scripts/ci/ios-sim/install-axe.sh
- name: Run smoke test
run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/SimpleX.app" "$RUNNER_TEMP/claude-test/smoke-test"
- name: Install Claude Code
run: |
npm install -g "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION"
claude --version
- name: Run the device test plan with Claude
timeout-minutes: 240
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
BACKEND_DIR: ${{ steps.backend.outcome == 'success' && format('{0}/backend', runner.temp) || '' }}
run: scripts/ci/ios-sim/claude-test.sh "$RUNNER_TEMP/claude-test"
- name: Report Claude usage
if: ${{ always() }}
run: scripts/ci/ios-sim/claude-usage.sh "$RUNNER_TEMP/claude-test"
- name: Stop test backend
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
run: scripts/ci/ios-sim/backend.sh stop "$RUNNER_TEMP/backend"
- name: Upload Claude test results
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: ios-sim-claude-test
path: ${{ runner.temp }}/claude-test
retention-days: 14
if-no-files-found: warn
- name: Upload backend logs
if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }}
uses: actions/upload-artifact@v7
with:
name: ios-sim-claude-backend-logs
path: |
${{ runner.temp }}/backend/*.log
${{ runner.temp }}/backend/seed.md
retention-days: 3
if-no-files-found: warn
+72
View File
@@ -0,0 +1,72 @@
name: packages
on:
pull_request:
paths:
- "packages/simplex-chat-nodejs/**"
- "packages/simplex-chat-client/types/typescript/**"
- "packages/simplex-chat-python/**"
- ".github/workflows/packages.yml"
concurrency:
# build.yml and this workflow both run for a pull request that changes the core and the packages
group: ${{ github.workflow }}-packages-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
NPM_SCOPE: ${{ vars.NPM_SCOPE }}
PYPI_PROJECT: ${{ vars.PYPI_PROJECT }}
PYPI_URL: ${{ vars.PYPI_URL }}
jobs:
addon:
runs-on: ubuntu-latest
outputs:
platforms: ${{ steps.plan.outputs.platforms }}
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Build plan
id: plan
env:
BASE: ${{ github.event.pull_request.base.sha }}
run: |
git fetch -q --depth=1 origin "$BASE"
python3 scripts/ci/packages_release.py build-plan --base "$BASE" >> "$GITHUB_OUTPUT"
build:
name: "build ${{ matrix.platform }}"
needs: addon
if: needs.addon.outputs.platforms != '[]'
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.addon.outputs.platforms) }}
defaults:
run:
shell: bash
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 22
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Build simplex-chat (npm) and add-on
run: scripts/ci/package-tests.sh build ${{ matrix.platform }}
- name: Test simplex-chat (npm and PyPI)
run: scripts/ci/package-tests.sh test
-6
View File
@@ -89,9 +89,3 @@ apps/android/app/release
apps/multiplatform/.kotlin/sessions
# anywhere in the tree: this file holds a real BTCPay api_key and webhook_secret
badge_service.ini
# Badge service Docker deployment: the Dockerfile, compose, and example config are tracked; the
# live config (a secret) and its data volume are not.
scripts/badge-service/badge_service.ini
scripts/badge-service/data/
scripts/badge-service/web/
+24 -1
View File
@@ -461,9 +461,14 @@ struct ContentView: View {
}
func connectViaUrl_(_ url: URL) {
// an app link must not tear down a redemption in flight
if isAppLink(url) && isBadgeLinkIssuing() { return }
dismissAllSheets() {
var path = url.path
if path == "/r" {
if isAppLink(url) {
// branched on the scheme before the connection dispatch, which an app link must never reach
openAppLink(url)
} else if path == "/r" {
showAlert(
NSLocalizedString("Relay address", comment: "alert title"),
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message")
@@ -498,6 +503,24 @@ struct ContentView: View {
}
}
// the app's own scheme, for every link that is not a connection link, which stays on simplex:
private let appLinkScheme = "simplexchat"
private let badgeLinkPath = "/badge/code/"
func isAppLink(_ url: URL) -> Bool {
url.scheme?.lowercased() == appLinkScheme
}
// a link type added in a later version reaches this build too, so an unknown path asks for an update
private func openAppLink(_ url: URL) {
let path = url.path
if path.hasPrefix(badgeLinkPath) {
openBadgeLink(String(path.dropFirst(badgeLinkPath.count)))
} else {
AlertManager.shared.showAlert(Alert(title: Text("This link is not supported by this app version. Please check for app updates.")))
}
}
final class AlertManager: ObservableObject {
static let shared = AlertManager()
@Published var presentAlert = false
+44 -3
View File
@@ -193,6 +193,8 @@ enum ChatCommand: ChatCmdProtocol {
case apiStandaloneFileInfo(url: String)
// badges
case apiRedeemBadgeCode(userId: Int64, code: String)
case apiPurchaseBadge(userId: Int64, echoedInvoiceId: String?, payment: ServicePayment)
case apiCreateBadgeInvoice(userId: Int64)
case apiGetBadgeState(userId: Int64)
case apiGetBadgeLedger(userId: Int64, badgePurchaseId: Int64)
case apiAckBadgeAlert(userId: Int64, badgePurchaseId: Int64, alertKind: BadgeAlertKind, snooze: Bool, episode: String)
@@ -419,6 +421,9 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)"
case let .apiStandaloneFileInfo(link): return "/_download info \(link)"
case let .apiRedeemBadgeCode(userId, code): return "/_redeem_badge_code \(userId) \(code)"
case let .apiPurchaseBadge(userId, echoedInvoiceId, payment):
return "/_badge purchase \(userId)\(echoedInvoiceId.map { " invoice=\($0)" } ?? "") \(encodeJSON(payment))"
case let .apiCreateBadgeInvoice(userId): return "/_badge invoice \(userId)"
case let .apiGetBadgeState(userId): return "/_badge state \(userId)"
case let .apiGetBadgeLedger(userId, badgePurchaseId): return "/_badge ledger \(userId) \(badgePurchaseId)"
case let .apiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode):
@@ -611,6 +616,8 @@ enum ChatCommand: ChatCmdProtocol {
case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile"
case .apiStandaloneFileInfo: return "apiStandaloneFileInfo"
case .apiRedeemBadgeCode: return "apiRedeemBadgeCode"
case .apiPurchaseBadge: return "apiPurchaseBadge"
case .apiCreateBadgeInvoice: return "apiCreateBadgeInvoice"
case .apiGetBadgeState: return "apiGetBadgeState"
case .apiGetBadgeLedger: return "apiGetBadgeLedger"
case .apiAckBadgeAlert: return "apiAckBadgeAlert"
@@ -667,9 +674,11 @@ enum ChatCommand: ChatCmdProtocol {
return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd))
case let .testStorageEncryption(key):
return .testStorageEncryption(key: obfuscate(key))
// a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
// a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString
case let .apiRedeemBadgeCode(userId, code):
return .apiRedeemBadgeCode(userId: userId, code: obfuscate(code))
case let .apiPurchaseBadge(userId, echoedInvoiceId, .apple(jws)):
return .apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: .apple(jws: obfuscate(jws)))
default: return self
}
}
@@ -719,6 +728,29 @@ enum ChatCommand: ChatCmdProtocol {
}
}
enum ServicePayment: Encodable {
case apple(jws: String)
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case let .apple(jws):
try container.encode("apple", forKey: .type)
try container.encode(jws, forKey: .jws)
}
}
private enum CodingKeys: String, CodingKey {
case type, jws
}
}
struct OpenStorePurchase: Decodable {
var invoiceId: String?
var transactionRef: String?
var creditError: BadgeIssueFailure?
}
// ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient.
// Spec: spec/api.md#ChatResponse0
enum ChatResponse0: Decodable, ChatAPIResult {
@@ -1051,7 +1083,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
// badges
// the full user, not UserRef: its profile carries the badge that setUserBadge just stored
case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool, badgeState: BadgeState?)
case badgeState(user: UserRef, badgeState: BadgeState?)
case badgeInvoice(user: UserRef, invoiceId: String)
case badgeState(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase]?)
case badgeLedger(user: UserRef, badgeLedger: [StatementEntry])
var responseType: String {
@@ -1105,6 +1138,7 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case .archiveImported: "archiveImported"
case .appSettings: "appSettings"
case .badgeRedeemed: "badgeRedeemed"
case .badgeInvoice: "badgeInvoice"
case .badgeState: "badgeState"
case .badgeLedger: "badgeLedger"
}
@@ -1161,7 +1195,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case let .archiveImported(archiveErrors): return String(describing: archiveErrors)
case let .appSettings(appSettings): return String(describing: appSettings)
case let .badgeRedeemed(u, redeemedBadge, newBadge, badgeState): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)\nbadgeState: \(String(describing: badgeState))")
case let .badgeState(u, badgeState): return withUser(u, String(describing: badgeState))
case let .badgeInvoice(u, invoiceId): return withUser(u, invoiceId)
case let .badgeState(u, badgeState, storePurchases): return withUser(u, "\(String(describing: badgeState))\nstorePurchases: \(String(describing: storePurchases ?? []))")
case let .badgeLedger(u, badgeLedger): return withUser(u, String(describing: badgeLedger))
}
}
@@ -1249,6 +1284,8 @@ enum ChatEvent: Decodable, ChatAPIResult {
// badges
case badgeChanged(user: User, badgeState: BadgeState?)
case badgeAlert(user: UserRef, badgeAlert: BadgeAlert)
case storePurchaseCredited(user: UserRef, invoiceId: String)
case storePurchaseRefused(user: UserRef, invoiceId: String, refusal: BadgeIssueFailure)
var responseType: String {
switch self {
@@ -1324,6 +1361,8 @@ enum ChatEvent: Decodable, ChatAPIResult {
case .contactPQEnabled: "contactPQEnabled"
case .badgeChanged: "badgeChanged"
case .badgeAlert: "badgeAlert"
case .storePurchaseCredited: "storePurchaseCredited"
case .storePurchaseRefused: "storePurchaseRefused"
}
}
@@ -1409,6 +1448,8 @@ enum ChatEvent: Decodable, ChatAPIResult {
case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)")
case let .badgeChanged(u, badgeState): return withUser(u, String(describing: badgeState))
case let .badgeAlert(u, badgeAlert): return withUser(u, String(describing: badgeAlert))
case let .storePurchaseCredited(u, invoiceId): return withUser(u, "invoiceId: \(invoiceId)")
case let .storePurchaseRefused(u, invoiceId, refusal): return withUser(u, "invoiceId: \(invoiceId)\nrefusal: \(refusal.tag)")
}
}
}
+17
View File
@@ -474,10 +474,27 @@ final class ChatModel: ObservableObject {
// another in its place; only the badge alert shows regardless. Set while rendering, so not published.
var chatListBanner: ChatListBanner?
var fileToOpen: (fileId: Int64, chatId: String?, open: (CIFile) -> Void)?
func bannerSlotFree(for banner: ChatListBanner) -> Bool {
chatListBanner == nil || chatListBanner == banner
}
func openWhenReceived(_ file: CIFile, open: @escaping (CIFile) -> Void) {
fileToOpen = (fileId: file.fileId, chatId: chatId, open: open)
}
func receivedFileToOpen(_ file: CIFile) -> Bool {
if let f = fileToOpen { f.fileId == file.fileId && f.chatId == chatId } else { false }
}
func openReceivedFile(_ file: CIFile) {
if let f = fileToOpen, receivedFileToOpen(file) {
fileToOpen = nil
f.open(file)
}
}
static let shared = ChatModel()
let im = ItemsModel.shared
+64 -15
View File
@@ -1850,8 +1850,8 @@ func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandida
try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer))
}
func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: String) async throws {
let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates)
func apiSendCallExtraInfo(_ contact: Contact, _ rtcSession: String?, _ rtcIceCandidates: String) async throws {
let extraInfo = WebRTCExtraInfo(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo))
}
@@ -2200,13 +2200,42 @@ func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user:
throw r.unexpected
}
// a refusal is thrown, as BREServiceError with the code
enum BadgePurchaseResult {
case held(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase])
case credited(user: User, badgeState: BadgeState?)
}
// log: false because a store receipt is a bearer secret, like a badge code - it is in the command.
func apiPurchaseBadge(_ userId: Int64, _ echoedInvoiceId: String?, _ payment: ServicePayment) async throws -> BadgePurchaseResult {
let r: ChatResponse2 = try await chatSendCmd(.apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: payment), log: false)
switch r {
case let .badgeState(user, badgeState, storePurchases): return .held(user: user, badgeState: badgeState, storePurchases: storePurchases ?? [])
case let .badgeRedeemed(user, _, _, badgeState): return .credited(user: user, badgeState: badgeState)
default: throw r.unexpected
}
}
func apiCreateBadgeInvoice(_ userId: Int64) async throws -> String {
let r: ChatResponse2 = try await chatSendCmd(.apiCreateBadgeInvoice(userId: userId))
if case let .badgeInvoice(_, invoiceId) = r { return invoiceId }
throw r.unexpected
}
// localized where the user can act on it; otherwise the error itself, so a screenshot says what happened
func redeemErrorText(_ error: Error) -> String {
func redeemErrorText(_ error: Error, purchase: Bool) -> String {
if case let .error(.badgeRedeemError(e)) = error as? ChatError {
switch e {
case .invalidCode: return NSLocalizedString("This code is not valid.", comment: "alert message")
case .serviceNotConfigured: return NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message")
case .badgeActive: return NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message")
case .invalidReceipt: break
case .serviceNotConfigured:
return purchase
? NSLocalizedString("This app version cannot buy badges.", comment: "alert message")
: NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message")
case .badgeActive:
return purchase
? NSLocalizedString("This profile already has a badge.", comment: "alert message")
: NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message")
case let .serviceError(code): if let text = badgeServiceErrorText(code) { return text }
case let .invalidResponse(message):
return String.localizedStringWithFormat(NSLocalizedString("The badge service sent an unexpected response: %@", comment: "alert message"), message)
@@ -2216,15 +2245,15 @@ func redeemErrorText(_ error: Error) -> String {
return String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error))
}
func apiGetBadgeState(_ userId: Int64) async throws -> BadgeState? {
func apiGetBadgeState(_ userId: Int64) async throws -> (badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) {
let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeState(userId: userId))
if case let .badgeState(_, badgeState) = r { return badgeState }
if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) }
throw r.unexpected
}
func apiGetBadgeStateSync(_ userId: Int64) throws -> BadgeState? {
func apiGetBadgeStateSync(_ userId: Int64) throws -> (badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) {
let r: ChatResponse2 = try chatSendCmdSync(.apiGetBadgeState(userId: userId))
if case let .badgeState(_, badgeState) = r { return badgeState }
if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) }
throw r.unexpected
}
@@ -2236,7 +2265,7 @@ func apiGetBadgeLedger(_ userId: Int64, _ badgePurchaseId: Int64) async throws -
func apiAckBadgeAlert(_ userId: Int64, _ badgePurchaseId: Int64, _ alertKind: BadgeAlertKind, snooze: Bool, episode: String) async throws -> BadgeState? {
let r: ChatResponse2 = try await chatSendCmd(.apiAckBadgeAlert(userId: userId, badgePurchaseId: badgePurchaseId, alertKind: alertKind, snooze: snooze, episode: episode))
if case let .badgeState(_, badgeState) = r { return badgeState }
if case let .badgeState(_, badgeState, _) = r { return badgeState }
throw r.unexpected
}
@@ -2365,6 +2394,7 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws
ChatReceiver.shared.start()
m.chatRunning = true
chatLastStartGroupDefault.set(Date.now)
Task { await BadgeStore.shared.presentUnfinished() }
if shouldDeleteDatabaseBackupsDefault.get() {
deleteDatabaseBackups()
shouldDeleteDatabaseBackupsDefault.set(false)
@@ -2393,6 +2423,7 @@ private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws {
m.currentUser = try apiSetActiveUser(userId, viewPwd: viewPwd)
m.users = try listUsers()
try getUserChatData()
Task { await BadgeStore.shared.presentUnfinished() }
}
func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: String? = nil) async throws {
@@ -2408,6 +2439,7 @@ func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: S
m.users = users
}
try await getUserChatDataAsync(keepingChatId: keepingChatId)
Task { await BadgeStore.shared.presentUnfinished() }
await MainActor.run {
if let currentUser = currentUser, var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) {
invitation.user = currentUser
@@ -2435,22 +2467,32 @@ func getUserChatData() throws {
private func loadBadgeState() {
do {
let userId = try currentUserId("loadBadgeState")
let badgeState = try apiGetBadgeStateSync(userId)
let (badgeState, storePurchases) = try apiGetBadgeStateSync(userId)
BadgeModel.shared.set(userId: userId, badgeState: badgeState)
BadgeStore.shared.setStorePurchases(userId, storePurchases)
} catch let error {
logger.error("loadBadgeState: \(responseError(error))")
}
}
private func loadBadgeStateAsync(_ userId: Int64) async {
func loadBadgeStateAsync(_ userId: Int64) async {
do {
let badgeState = try await apiGetBadgeState(userId)
await MainActor.run { BadgeModel.shared.set(userId: userId, badgeState: badgeState) }
let (badgeState, storePurchases) = try await apiGetBadgeState(userId)
await MainActor.run {
BadgeModel.shared.set(userId: userId, badgeState: badgeState)
BadgeStore.shared.setStorePurchases(userId, storePurchases)
}
} catch let error {
logger.error("loadBadgeState: \(responseError(error))")
}
}
func loadCurrentBadgeState() async {
if let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) {
await loadBadgeStateAsync(userId)
}
}
private func getUserChatDataAsync(keepingChatId: String?) async throws {
let m = ChatModel.shared
let tm = ChatTagsModel.shared
@@ -2873,6 +2915,9 @@ func processReceivedMsg(_ res: ChatEvent) async {
await chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileComplete(user, aChatItem):
await chatItemSimpleUpdate(user, aChatItem)
if active(user), let file = aChatItem.chatItem.file, m.receivedFileToOpen(file) {
await MainActor.run { m.openReceivedFile(file) }
}
case let .rcvFileSndCancelled(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
@@ -2942,7 +2987,7 @@ func processReceivedMsg(_ res: ChatEvent) async {
}
case let .callExtraInfo(_, contact, extraInfo):
await withCall(contact) { _ in
await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates))
await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates, session: extraInfo.rtcSession))
}
case let .callEnded(_, contact):
if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
@@ -3057,6 +3102,10 @@ func processReceivedMsg(_ res: ChatEvent) async {
BadgeModel.shared.setAlert(userId: user.userId, alert: badgeAlert)
}
}
case let .storePurchaseCredited(user, invoiceId):
Task { await BadgeStore.shared.storePurchaseCredited(user, invoiceId) }
case let .storePurchaseRefused(user, invoiceId, refusal):
Task { await BadgeStore.shared.storePurchaseRefused(user, invoiceId, refusal) }
default:
logger.debug("unsupported event: \(res.responseType)")
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "agent.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="124.5 -878.5 711 797" width="21.41px" fill="#000000"><path d="M564.12,-698.86 C564.12,-652.47 526.51,-614.86 480.12,-614.86 C433.73,-614.86 396.12,-652.47 396.12,-698.86 C396.12,-745.25 433.73,-782.86 480.12,-782.86 C526.51,-782.86 564.12,-745.25 564.12,-698.86 Z M373.01,-371.05 C373.01,-324.66 335.4,-287.05 289.01,-287.05 C242.62,-287.05 205.01,-324.66 205.01,-371.05 C205.01,-417.44 242.62,-455.05 289.01,-455.05 C335.4,-455.05 373.01,-417.44 373.01,-371.05 Z M755.03,-371.07 C755.03,-324.68 717.42,-287.07 671.03,-287.07 C624.63,-287.07 587.03,-324.68 587.03,-371.07 C587.03,-417.46 624.63,-455.07 671.03,-455.07 C717.42,-455.07 755.03,-417.46 755.03,-371.07 Z"/></svg>

After

Width:  |  Height:  |  Size: 737 B

+3 -1
View File
@@ -43,7 +43,8 @@ struct SimpleXApp: App {
.environmentObject(chatModel)
.environmentObject(AppTheme.shared)
.onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)")
// an app link can carry a secret, such as a badge code that anyone who reads it can redeem
logger.debug("ContentView.onOpenURL: \(isAppLink(url) ? "app link" : url.absoluteString)")
if AppChatState.shared.value == .active {
chatModel.appOpenUrl = url
} else {
@@ -100,6 +101,7 @@ struct SimpleXApp: App {
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations()
}
Task { await BadgeStore.shared.presentUnfinished() }
if let url = chatModel.appOpenUrlLater {
await MainActor.run {
chatModel.appOpenUrlLater = nil
+234 -32
View File
@@ -9,6 +9,7 @@
import Foundation
import Combine
import StoreKit
import SimpleXChat
// TODO [badges] product ids will come from app config and prices from the badge service catalog;
// hardcoded here so the App Store integration can be tested before the purchase API lands.
@@ -27,11 +28,27 @@ let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in
BadgePeriod.allCases.map { badgeProductId(level, $0) }
}
// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id.
// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction,
// which is how the service learns which invoice a store transaction settles.
// the only products sent to the badge service: nothing delivers a subscription yet
let badgeOneTimeProductIds: Set<String> = Set(BadgeLevel.allCases.map { badgeProductId($0, .oneMonth) })
// subscriptions are for sale once renewals are delivered
let badgePeriodsForSale: [BadgePeriod] = [.oneMonth]
// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core.
// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction.
func newBadgeInvoiceId() -> UUID { UUID() }
// the page's app flag rides in the fragment, which never reaches the service
let badgePageUrl = "https://badges.simplex.chat/#/tier?app=true"
// the dev deployment, for testing store purchases: swap with the line above
// let badgePageUrl = "https://smp7.simplex.im/#/tier?app=true"
// where the store allows a link out to the badge page: the US for now, a set expected to widen.
// An unknown storefront does not count, as this decides whether the store sees a link out of the app.
var badgeBrowserAllowed: Bool {
SKPaymentQueue.default().storefront?.countryCode == "USA"
}
enum BadgePrice {
case loading
case price(String)
@@ -49,21 +66,32 @@ struct BadgeStoreReceipt {
// the signed token the badge service verifies - never transaction.jsonRepresentation
let jws: String
let productId: String
let transactionId: UInt64
let invoiceId: UUID?
let environment: String?
let signatureVerified: Bool
let transaction: Transaction
var echoedInvoiceId: String? { invoiceId.map(coreInvoiceId) }
}
// core mints the id in lower case, and UUID formats it in upper case
func coreInvoiceId(_ invoiceId: UUID) -> String { invoiceId.uuidString.lowercased() }
enum BadgePurchaseOutcome {
case purchased(BadgeStoreReceipt)
case pending
case pending(invoiceId: String?)
case cancelled
}
enum BadgePurchaseState {
case issuing
case waitingForApproval
}
enum BadgeStoreError: Error {
case productUnavailable(productId: String)
case unknownPurchaseResult
case noActiveProfile
case invalidInvoiceId(String)
}
final class BadgeStore: ObservableObject {
@@ -73,14 +101,53 @@ final class BadgeStore: ObservableObject {
@Published private var state: LoadState = .notLoaded
private var products: [String: Product] = [:]
// core's open store purchases for the profile they were read for: core knows whose a purchase is
@Published private var storePurchases: (userId: Int64, purchases: [OpenStorePurchase])? = nil
// whether a store sheet this run opened has not returned
@Published private var buying = false
// kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing
// by invoice id, so a pending purchase shows only under the profile whose record it names
@Published private var waitingForApproval: Set<String> = []
// set once presentUnfinished has read the store: until then, an Ask to Buy approved while the app was
// closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again
@Published private var reconciledOnce = false
// the sweep has no buyer waiting and runs at four triggers, so a failure that cannot clear is told once a run
private var sweepFailureShown = false
private var transactionUpdates: Task<Void, Never>? = nil
private init() {}
func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice {
func purchaseState(_ userId: Int64?) -> BadgePurchaseState? {
let purchases = openStorePurchases(userId)
if purchases.contains(where: { $0.transactionRef != nil }) { return .issuing }
if purchases.contains(where: { $0.invoiceId.map(waitingForApproval.contains) == true }) { return .waitingForApproval }
return nil
}
func creditError(_ userId: Int64?) -> BadgeIssueFailure? {
openStorePurchases(userId).compactMap(\.creditError).first
}
var checkingPurchases: Bool { !reconciledOnce }
func canBuy(_ userId: Int64?) -> Bool {
reconciledOnce && !buying && purchaseState(userId) == nil
}
func setStorePurchases(_ userId: Int64, _ purchases: [OpenStorePurchase]) {
storePurchases = (userId, purchases)
}
private func openStorePurchases(_ userId: Int64?) -> [OpenStorePurchase] {
guard let storePurchases, storePurchases.userId == userId else { return [] }
return storePurchases.purchases
}
func price(_ level: BadgeLevel, _ period: BadgePeriod, compact: Bool = true) -> BadgePrice {
switch state {
case .notLoaded, .loading: return .loading
case .loaded, .failed:
if let p = products[badgeProductId(level, period)] { return .price(compactPrice(p)) }
if let p = products[badgeProductId(level, period)] { return .price(compact ? compactPrice(p) : p.displayPrice) }
return .unavailable
}
}
@@ -119,33 +186,158 @@ final class BadgeStore: ObservableObject {
}
}
func purchase(_ level: BadgeLevel, _ period: BadgePeriod, invoiceId: UUID) async throws -> BadgePurchaseOutcome {
// A one-time purchase has its core record before the store charges, and every store outcome reaches it.
func purchase(_ level: BadgeLevel, _ period: BadgePeriod) async throws -> BadgePurchaseOutcome {
let productId = badgeProductId(level, period)
guard let product = await MainActor.run(body: { products[productId] }) else {
throw BadgeStoreError.productUnavailable(productId: productId)
}
switch try await product.purchase(options: [.appAccountToken(invoiceId)]) {
case let .success(verification):
let transaction: Transaction
let signatureVerified: Bool
switch verification {
case let .verified(t):
transaction = t
signatureVerified = true
case let .unverified(t, _):
transaction = t
signatureVerified = false
// a subscription is never sent to core, so nothing would ever finish it later
guard badgeOneTimeProductIds.contains(productId) else {
let outcome = try await storePurchase(product, newBadgeInvoiceId())
if case let .purchased(receipt) = outcome { await receipt.transaction.finish() }
return outcome
}
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else {
throw BadgeStoreError.noActiveProfile
}
let invoice = try await apiCreateBadgeInvoice(userId)
guard let invoiceId = UUID(uuidString: invoice) else { throw BadgeStoreError.invalidInvoiceId(invoice) }
await MainActor.run { buying = true }
await loadBadgeStateAsync(userId)
do {
let outcome = try await storePurchase(product, invoiceId)
if case let .purchased(receipt) = outcome, receipt.signatureVerified {
try await handOver(receipt)
}
// nothing is delivered in this build, so the transaction is finished right away; once the
// service issues credentials it must only be finished after the credential is stored
await transaction.finish()
return .purchased(storeReceipt(verification.jwsRepresentation, transaction, signatureVerified))
case .pending: return .pending
await MainActor.run { buying = false }
return outcome
} catch let error {
await MainActor.run { buying = false }
throw error
}
}
private func storePurchase(_ product: Product, _ invoiceId: UUID) async throws -> BadgePurchaseOutcome {
switch try await product.purchase(options: [.appAccountToken(invoiceId)]) {
case let .success(verification): return .purchased(storeReceipt(verification))
case .pending:
let pending = coreInvoiceId(invoiceId)
await MainActor.run { _ = waitingForApproval.insert(pending) }
return .pending(invoiceId: pending)
case .userCancelled: return .cancelled
@unknown default: throw BadgeStoreError.unknownPurchaseResult
}
}
// Core holds the receipt and credits it; the transaction stays unfinished until core answers it credited
// or refused, as an unfinished transaction is what the store re-delivers if anything is lost on the way.
private func handOver(_ receipt: BadgeStoreReceipt) async throws {
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return }
// a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer
do {
switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws)) {
case let .held(user, badgeState, storePurchases):
await MainActor.run {
// the answer is the owner's, which may be another profile and a hidden one
if active(user) {
BadgeModel.shared.set(userId: user.userId, badgeState: badgeState)
setStorePurchases(user.userId, storePurchases)
}
}
case let .credited(user, badgeState):
await MainActor.run {
if active(user) {
BadgeModel.shared.set(userId: user.userId, badgeState: badgeState)
ChatModel.shared.updateUser(user)
if badgeState?.shown == true { UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) }
}
}
await receipt.transaction.finish()
}
} catch let error where badgeReceiptRefused(error) {
logger.error("BadgeStore.handOver: \(responseError(error))")
await receipt.transaction.finish()
}
}
func storePurchaseCredited(_ user: UserRef, _ invoiceId: String) async {
await MainActor.run {
// an open badges screen is the notice only for the active profile, whose badge it shows
if !active(user) || !badgesViewShown { showBadgeAddedAlert(user) }
}
await storePurchaseResolved(user, invoiceId)
}
func storePurchaseRefused(_ user: UserRef, _ invoiceId: String, _ refusal: BadgeIssueFailure) async {
await MainActor.run {
if active(user) && openStorePurchases(user.userId).contains(where: { $0.invoiceId == invoiceId }) {
showAlert(refusal.purchaseAlertTitle, message: refusal.purchaseText)
}
}
await storePurchaseResolved(user, invoiceId)
}
private func storePurchaseResolved(_ user: UserRef, _ invoiceId: String) async {
// the row goes before the store is read, or the screen shows it open while the store answers;
// the transaction is finished whoever owns it, as only the app can, while the cached row is the active profile's
await MainActor.run {
if active(user), let open = storePurchases, open.userId == user.userId {
storePurchases = (open.userId, open.purchases.filter { $0.invoiceId != invoiceId })
}
}
for await verification in Transaction.unfinished {
let receipt = storeReceipt(verification)
if receipt.echoedInvoiceId == invoiceId {
await receipt.transaction.finish()
return
}
}
}
// at launch, on return to the foreground and on a profile switch, never on a timer
func presentUnfinished() async {
await listenForTransactions()
for await verification in Transaction.unfinished {
await reconcile(verification)
}
await loadCurrentBadgeState()
await MainActor.run { reconciledOnce = true }
}
// transactions the store settles outside a purchase call, such as an approved Ask to Buy
@MainActor
private func listenForTransactions() {
if transactionUpdates == nil {
transactionUpdates = Task.detached {
for await verification in Transaction.updates {
await BadgeStore.shared.reconcile(verification)
}
}
}
}
// an unverified one-time transaction is never sent, and left unfinished rather than lost
private func reconcile(_ verification: VerificationResult<Transaction>) async {
let receipt = storeReceipt(verification)
if !badgeOneTimeProductIds.contains(receipt.productId) {
await receipt.transaction.finish()
} else if receipt.signatureVerified {
// the receipt stays unfinished, so the next sweep hands it over again
do { try await handOver(receipt) } catch let error {
logger.error("BadgeStore.reconcile: \(responseError(error))")
await MainActor.run {
// showAlert presents on the top view controller, which the launch sweep can precede;
// an alert with nowhere to go must not spend the one this run gets
if !sweepFailureShown, getTopViewController() != nil {
sweepFailureShown = true
showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(error, purchase: true))
}
}
}
}
}
@MainActor
private func startLoading() -> Bool {
switch state {
@@ -169,15 +361,25 @@ private func compactPrice(_ product: Product) -> String {
: product.displayPrice
}
private func storeReceipt(_ jws: String, _ t: Transaction, _ signatureVerified: Bool) -> BadgeStoreReceipt {
var environment: String? = nil
if #available(iOS 16.0, *) { environment = t.environment.rawValue }
private func storeReceipt(_ verification: VerificationResult<Transaction>) -> BadgeStoreReceipt {
let (t, signatureVerified) = switch verification {
case let .verified(t): (t, true)
case let .unverified(t, _): (t, false)
}
return BadgeStoreReceipt(
jws: jws,
jws: verification.jwsRepresentation,
productId: t.productID,
transactionId: t.id,
invoiceId: t.appAccountToken,
environment: environment,
signatureVerified: signatureVerified
signatureVerified: signatureVerified,
transaction: t
)
}
// the codes core refuses a receipt with for good, after which the store may stop re-delivering it;
// a sweep that hands over a receipt refused while the app was not running learns it only from this answer
private func badgeReceiptRefused(_ error: Error) -> Bool {
if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError {
return code == .receiptInvalid || code == .receiptUsed || code == .testPurchase
}
return false
}
@@ -9,15 +9,9 @@
import SwiftUI
import SimpleXChat
struct BadgeUserPreview<Trailing: View>: View {
struct BadgeUserPreview: View {
@EnvironmentObject var chatModel: ChatModel
let level: BadgeLevel
let trailing: () -> Trailing
init(level: BadgeLevel, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) {
self.level = level
self.trailing = trailing
}
var body: some View {
let user = chatModel.currentUser
@@ -29,12 +23,9 @@ struct BadgeUserPreview<Trailing: View>: View {
)
return VStack(spacing: 12) {
ProfileImage(imageStr: user?.image, size: 128)
HStack(alignment: .center, spacing: 6) {
NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle)
.lineLimit(1)
.minimumScaleFactor(0.75)
trailing()
}
NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
}
}
@@ -0,0 +1,131 @@
//
// BadgesCheckOrderView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 29.09.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct BadgesCheckOrderView: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatModel: ChatModel
@ObservedObject private var store = BadgeStore.shared
let level: BadgeLevel
let period: BadgePeriod
@State private var purchasing = false
// presented from this view, not AlertManager: its host is behind the sheet these views open in
@State private var alert: SomeAlert?
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .center, spacing: 16) {
Text("Check your order")
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 0) {
orderRow("Your badge", Text(level.title))
orderRow("Duration", Text(period.label))
Divider()
.padding(.top, 8)
.padding(.bottom, 6)
orderRow("Total", period.priceText(store.price(level, period, compact: false)))
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.top, 20)
Spacer(minLength: 20)
VStack(spacing: 10) {
payButton()
.padding(.vertical, 10)
BadgeBillingFooter(period: period)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
.padding(.horizontal, 25)
.padding(.top, 0)
.padding(.bottom, 20)
.frame(minHeight: g.size.height)
}
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
.task { await store.load() }
.alert(item: $alert) { $0.alert }
}
private func orderRow(_ title: LocalizedStringKey, _ value: Text) -> some View {
HStack {
Text(title)
.foregroundColor(theme.colors.secondary)
Spacer()
value
.fontWeight(.medium)
}
.font(.body)
.padding(.vertical, 8)
}
private func payButton() -> some View {
let price = store.price(level, period, compact: false)
let disabled = !price.canPurchase || purchasing || !store.canBuy(chatModel.currentUser?.userId)
return Button {
purchase()
} label: {
period.payText(price)
}
.buttonStyle(OnboardingButtonStyle(isDisabled: disabled))
.disabled(disabled)
}
private func purchase() {
purchasing = true
Task {
do {
if case let .purchased(receipt) = try await store.purchase(level, period), !receipt.signatureVerified {
await MainActor.run {
alert = SomeAlert(
alert: mkAlert(
title: "Cannot verify this purchase",
message: "The store returned a transaction that Apple has not signed. SimpleX cannot verify this purchase with the App Store."
),
id: "badgePurchaseUnverified"
)
}
}
await MainActor.run { purchasing = false }
} catch let error {
logger.error("BadgesCheckOrderView.purchase: \(String(describing: error))")
await MainActor.run {
purchasing = false
alert = SomeAlert(
alert: Alert(
title: Text("Purchase error"),
message: Text(verbatim: redeemErrorText(error, purchase: true))
),
id: "badgePurchaseError"
)
}
}
}
}
}
struct BadgesCheckOrderView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesCheckOrderView(level: .supporter, period: .oneMonth)
}
}
}
@@ -1,5 +1,5 @@
//
// BadgesYourLevelView.swift
// BadgesChooseBadgeView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.07.2026.
@@ -23,17 +23,24 @@ enum BadgeLevel: String, CaseIterable, Identifiable {
}
}
var filesDescription: LocalizedStringKey {
var fileSize: LocalizedStringKey {
switch self {
case .supporter: "Send 2GB files"
case .legend: "Send 5GB files"
case .supporter: "Files up to 2 GB"
case .legend: "Files up to 5 GB"
}
}
var tagline: LocalizedStringKey {
var fileStorage: LocalizedStringKey {
switch self {
case .supporter: "Optional profile badge\nand 2GB files"
case .legend: "Optional profile badge\nand 5GB files"
case .supporter: "Stored for 7 days"
case .legend: "Stored for 21 days"
}
}
var summary: LocalizedStringKey {
switch self {
case .supporter: "Supporter: 2 GB files available for 7 days."
case .legend: "Legend: 5 GB files available for 21 days."
}
}
@@ -45,32 +52,33 @@ enum BadgeLevel: String, CaseIterable, Identifiable {
}
}
struct BadgesYourLevelView: View {
struct BadgesChooseBadgeView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject private var store = BadgeStore.shared
@State private var selectedLevel: BadgeLevel = .supporter
@State private var continueActive = false
@State private var howItWorksActive = false
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .center, spacing: 16) {
Text("Your level")
Text("Choose your badge")
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
BadgeUserPreview(level: selectedLevel) {
Image(systemName: "chevron.down")
.font(.body)
.foregroundColor(theme.colors.primary)
}
.padding(.top, 4)
Text("Larger files that stay available longer.")
.font(.body)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 20)
BadgeUserPreview(level: selectedLevel)
.padding(.top, 4)
Spacer(minLength: 12)
// fixedSize + maxHeight on the cards so both match the taller one when a
// store price wraps in one of them
@@ -80,13 +88,20 @@ struct BadgesYourLevelView: View {
}
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 20)
Spacer(minLength: 12)
VStack(spacing: 10) {
continueButton()
.padding(.vertical, 10)
howItWorksButton()
.frame(height: 22)
// redeeming a code is here only when Support SimpleX offers the browser instead
Group {
if badgeBrowserAllowed {
RedeemCodeButton()
} else {
Color.clear
}
}
.frame(height: 22)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
@@ -110,17 +125,20 @@ struct BadgesYourLevelView: View {
Image(badgeImageName(level.badgeType))
.resizable()
.scaledToFit()
.frame(width: 60, height: 60)
.padding(.top, 20)
.frame(width: 44, height: 44)
.padding(.top, 16)
Text(level.title)
.font(.title3)
.fontWeight(.bold)
Text(level.filesDescription)
.font(.subheadline)
.foregroundColor(theme.colors.secondary)
BadgePeriod.monthly.priceText(store.price(level, .monthly))
.font(.body)
.padding(.bottom, 20)
VStack(spacing: 2) {
Text(level.fileSize)
Text(level.fileStorage)
}
.font(.subheadline)
.foregroundColor(theme.colors.secondary)
.padding(.bottom, 16)
}
.multilineTextAlignment(.center)
.padding(.horizontal, 12)
@@ -145,30 +163,7 @@ struct BadgesYourLevelView: View {
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
NavigationLink(isActive: $continueActive) {
BadgesPayView(level: selectedLevel)
.modifier(ThemedBackground())
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
private func howItWorksButton() -> some View {
ZStack {
Button {
howItWorksActive = true
} label: {
HStack(spacing: 4) {
Image(systemName: "info.circle")
Text("How badges protect your privacy").fontWeight(.medium)
}
.font(.body)
}
NavigationLink(isActive: $howItWorksActive) {
BadgesHowItWorksView()
BadgesHowLongView(level: selectedLevel)
.modifier(ThemedBackground())
} label: {
EmptyView()
@@ -179,10 +174,10 @@ struct BadgesYourLevelView: View {
}
}
struct BadgesYourLevelView_Previews: PreviewProvider {
struct BadgesChooseBadgeView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesYourLevelView()
BadgesChooseBadgeView()
}
}
}
@@ -0,0 +1,222 @@
//
// BadgesHowLongView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.07.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
// TODO [badges]: replace with types produced by the badge purchase API when it lands.
enum BadgePeriod: String, CaseIterable, Identifiable {
case oneMonth
case monthly
case annual
var id: String { rawValue }
var icon: String {
switch self {
case .oneMonth: "calendar"
case .monthly: "arrow.clockwise"
case .annual: "arrow.clockwise"
}
}
var label: LocalizedStringKey {
switch self {
case .oneMonth: "1 month"
case .monthly: "Monthly"
case .annual: "Annual"
}
}
var months: Int {
switch self {
case .oneMonth: 1
case .monthly: 1
case .annual: 12
}
}
func priceText(_ price: BadgePrice) -> Text {
switch price {
case .loading: return Text(verbatim: "…")
case .unavailable: return Text(verbatim: "—")
case let .price(p):
switch self {
case .oneMonth: return Text(verbatim: p)
case .monthly: return Text("\(p)/month")
case .annual: return Text("\(p)/year")
}
}
}
func payText(_ price: BadgePrice) -> Text {
switch price {
case .loading: return Text("Loading…")
case .unavailable: return Text("Not available")
case let .price(p):
switch self {
case .oneMonth: return Text("Pay \(p)")
case .monthly: return Text("Pay \(p)/month")
case .annual: return Text("Pay \(p)/year")
}
}
}
}
struct BadgesHowLongView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject private var store = BadgeStore.shared
let level: BadgeLevel
@State private var selectedPeriod: BadgePeriod = badgePeriodsForSale.contains(.monthly) ? .monthly : .oneMonth
@State private var continueActive = false
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .center, spacing: 16) {
Text("How long?")
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text(level.summary)
.font(.body)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
BadgeUserPreview(level: level)
.padding(.top, 4)
Spacer(minLength: 20)
// fixedSize + maxHeight on the cards so they all match the tallest one -
// only Annual carries a savings line, and prices wrap at large fonts
HStack(alignment: .top, spacing: 12) {
ForEach(BadgePeriod.allCases) { periodCard($0) }
}
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 20)
VStack(spacing: 10) {
continueButton()
.padding(.vertical, 10)
BadgeBillingFooter(period: selectedPeriod)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
.padding(.horizontal, 25)
.padding(.top, 0)
.padding(.bottom, 20)
.frame(minHeight: g.size.height)
}
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
.task { await store.load() }
}
private func periodCard(_ period: BadgePeriod) -> some View {
let isSelected = period == selectedPeriod
let forSale = badgePeriodsForSale.contains(period)
return Button {
selectedPeriod = period
} label: {
VStack(spacing: 12) {
Image(systemName: period.icon)
.resizable()
.scaledToFit()
.frame(width: 32, height: 32)
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
.opacity(forSale ? 1 : 0.4)
Text(period.label)
.font(.subheadline)
period.priceText(store.price(level, period))
.font(.headline)
if let percent = savingsPercent(period) {
Text("\(percent)% off")
.font(.footnote)
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
}
}
.foregroundColor(forSale ? nil : theme.colors.secondary)
.multilineTextAlignment(.center)
.padding(.vertical, 25)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2)
)
}
.buttonStyle(.plain)
.disabled(!forSale)
}
private func savingsPercent(_ period: BadgePeriod) -> Int? {
period == .annual ? store.annualSavings(level) : nil
}
private func continueButton() -> some View {
ZStack {
Button {
continueActive = true
} label: {
Text("Continue")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
NavigationLink(isActive: $continueActive) {
BadgesCheckOrderView(level: level, period: selectedPeriod)
.modifier(ThemedBackground(grouped: true))
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
}
struct BadgeBillingFooter: View {
@EnvironmentObject var theme: AppTheme
let period: BadgePeriod
var body: some View {
Text(billingFooter)
.font(.footnote)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.frame(height: 22)
}
private var billingFooter: LocalizedStringKey {
// TODO [badges] from now only because a purchase is refused while a badge is held (refuseWhileBadgeHeld);
// a top-up must count from the end of the existing balance
let endDate = Calendar.current.date(byAdding: .month, value: period.months, to: Date()) ?? Date()
let date = DateFormatter.localizedString(from: endDate, dateStyle: .long, timeStyle: .none)
switch period {
case .monthly, .annual: return "Renews on \(date). Cancel anytime."
case .oneMonth: return "Ends on \(date)."
}
}
}
struct BadgesHowLongView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesHowLongView(level: .supporter)
}
}
}
@@ -1,269 +0,0 @@
//
// BadgesPayView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 28.07.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
// TODO [badges]: replace with types produced by the badge purchase API when it lands.
enum BadgePeriod: String, CaseIterable, Identifiable {
case oneMonth
case monthly
case annual
var id: String { rawValue }
var icon: String {
switch self {
case .oneMonth: "calendar"
case .monthly: "arrow.clockwise"
case .annual: "arrow.clockwise"
}
}
var label: LocalizedStringKey {
switch self {
case .oneMonth: "1 month"
case .monthly: "Monthly"
case .annual: "Annual"
}
}
func priceText(_ price: BadgePrice) -> Text {
switch price {
case .loading: return Text(verbatim: "…")
case .unavailable: return Text(verbatim: "—")
case let .price(p):
switch self {
case .oneMonth: return Text(verbatim: p)
case .monthly: return Text("\(p)/month")
case .annual: return Text("\(p)/year")
}
}
}
func payText(_ price: BadgePrice) -> Text {
switch price {
case .loading: return Text("Loading…")
case .unavailable: return Text("Not available")
case let .price(p):
switch self {
case .oneMonth: return Text("Pay \(p)")
case .monthly: return Text("Pay \(p)/month")
case .annual: return Text("Pay \(p)/year")
}
}
}
}
struct BadgesPayView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject private var store = BadgeStore.shared
let level: BadgeLevel
@State private var selectedPeriod: BadgePeriod = .monthly
@State private var purchasing = false
// presented from this view, not AlertManager: its host is behind the sheet these views open in
@State private var alert: SomeAlert?
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .center, spacing: 16) {
Text(level.title)
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
BadgeUserPreview(level: level)
.padding(.top, 4)
Text(level.tagline)
.font(.body)
.foregroundColor(theme.colors.onBackground)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
Spacer(minLength: 20)
// fixedSize + maxHeight on the cards so all three match the tallest one -
// only Annual carries a savings line, and prices wrap at large fonts
HStack(alignment: .top, spacing: 12) {
periodCard(.oneMonth)
periodCard(.monthly)
periodCard(.annual)
}
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 20)
VStack(spacing: 10) {
payButton()
.padding(.vertical, 10)
Text(billingFooter)
.font(.footnote)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.frame(height: 22)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
.padding(.horizontal, 25)
.padding(.top, 0)
.padding(.bottom, 20)
.frame(minHeight: g.size.height)
}
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
.task { await store.load() }
.alert(item: $alert) { $0.alert }
}
private func periodCard(_ period: BadgePeriod) -> some View {
let isSelected = period == selectedPeriod
return Button {
selectedPeriod = period
} label: {
VStack(spacing: 12) {
Image(systemName: period.icon)
.resizable()
.scaledToFit()
.frame(width: 32, height: 32)
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
Text(period.label)
.font(.title3)
.fontWeight(.bold)
period.priceText(store.price(level, period))
.font(.body)
if let percent = savingsPercent(period) {
Text("Save \(percent)%")
.font(.footnote)
.foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary)
}
}
.multilineTextAlignment(.center)
.padding(.vertical, 25)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2)
)
}
.buttonStyle(.plain)
}
private func savingsPercent(_ period: BadgePeriod) -> Int? {
period == .annual ? store.annualSavings(level) : nil
}
private func payButton() -> some View {
let price = store.price(level, selectedPeriod)
let disabled = !price.canPurchase || purchasing
return Button {
purchase()
} label: {
selectedPeriod.payText(price)
}
.buttonStyle(OnboardingButtonStyle(isDisabled: disabled))
.disabled(disabled)
}
private func purchase() {
let period = selectedPeriod
let invoiceId = newBadgeInvoiceId()
purchasing = true
Task {
do {
let outcome = try await store.purchase(level, period, invoiceId: invoiceId)
await MainActor.run {
purchasing = false
switch outcome {
case let .purchased(receipt): showPurchasedAlert(receipt, invoiceId)
case .pending:
alert = SomeAlert(
alert: mkAlert(
title: "Purchase pending",
message: "The purchase is awaiting approval. This build does not deliver purchases approved later."
),
id: "badgePurchasePending"
)
case .cancelled: break
}
}
} catch let error {
logger.error("BadgesPayView.purchase: \(String(describing: error))")
await MainActor.run {
purchasing = false
alert = SomeAlert(
alert: Alert(
title: Text("Purchase error"),
message: Text(verbatim: String(describing: error))
),
id: "badgePurchaseError"
)
}
}
}
}
// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands.
private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) {
let returnedInvoice: String
if let returned = receipt.invoiceId {
returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)"
} else {
returnedInvoice = "none"
}
var lines = [
"Product: \(receipt.productId)",
"Invoice: \(invoiceId.uuidString)",
"Invoice returned by Apple: \(returnedInvoice)",
"Transaction: \(receipt.transactionId)"
]
if let environment = receipt.environment { lines.append("Environment: \(environment)") }
lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")")
lines.append("Token: \(receipt.jws.count) bytes")
let summary = lines.joined(separator: "\n")
// logged as well as shown: the alert races StoreKit's own sheets, the log always lands
logger.debug("badge purchase succeeded\n\(summary)")
alert = SomeAlert(
alert: Alert(
title: Text("Purchase successful"),
message: Text(verbatim: summary),
primaryButton: .default(Text(verbatim: "Copy token")) { UIPasteboard.general.string = receipt.jws },
secondaryButton: .cancel(Text("Ok"))
),
id: "badgePurchased"
)
}
private var billingFooter: LocalizedStringKey {
// TODO [badges] source the actual date from the purchase state machine when wired.
var comps = DateComponents(); comps.year = 2026; comps.month = 7; comps.day = 22
let stubDate = Calendar.current.date(from: comps) ?? Date()
let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none)
switch selectedPeriod {
case .monthly, .annual: return "Renews on \(date). Cancel anytime."
case .oneMonth: return "Ends on \(date)."
}
}
}
struct BadgesPayView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BadgesPayView(level: .supporter)
}
}
}
@@ -0,0 +1,100 @@
//
// BadgesPurchaseStateView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 29.09.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct BadgesPurchaseStateView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) private var dismiss
let title: LocalizedStringKey
var message: LocalizedStringKey? = nil
var failure: BadgeIssueFailure? = nil
var showsAsSheet: Bool = false
var body: some View {
GeometryReader { g in
ScrollView {
content
.padding(.horizontal, 25)
.padding(.top, showsAsSheet ? 48 : 0)
.padding(.bottom, 20)
.frame(minHeight: g.size.height)
}
}
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
}
private var content: some View {
VStack(alignment: .center, spacing: 16) {
Text(title)
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
if let message {
Text(message)
.font(.body)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
if let failure {
VStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.red)
Text(failure.purchaseText)
.font(.body)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer()
ProgressView().scaleEffect(2)
Spacer()
VStack(spacing: 10) {
Button {
dismiss()
} label: {
Text("Dismiss")
.font(.body)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
.padding()
}
.padding(.vertical, 10)
Color.clear
.frame(height: 22)
}
}
}
}
extension BadgePurchaseState {
var title: LocalizedStringKey {
switch self {
case .issuing: "Issuing your badge"
case .waitingForApproval: "Waiting for approval"
}
}
var message: LocalizedStringKey {
switch self {
case .issuing: "Your payment is complete. The badge will be added to this profile."
case .waitingForApproval: "Nothing has been charged."
}
}
}
@@ -33,10 +33,52 @@ private func formatBadgeCodeInput(_ s: String) -> String {
return groups.joined(separator: "-")
}
enum BadgeRedeemOutcome {
case redeemed
case refused(message: String)
case cancelled
}
// sets the badge before returning, so a caller that dismisses on .redeemed lands on Your Badge
// instead of showing the switch from Support. .cancelled when the user cancels the retry alert.
func redeemBadgeCode(_ user: User, _ code: String) async -> BadgeRedeemOutcome {
do {
guard let redeemed = try await apiRedeemBadgeCode(user.userId, code) else { return .cancelled }
return await MainActor.run { () -> BadgeRedeemOutcome in
BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState)
ChatModel.shared.updateUser(redeemed.user)
if let badgeState = redeemed.badgeState, !badgeState.shown {
// a replay adds no purchase; a fresh code's badge can be retired on arrival
return .refused(message: redeemed.newBadge
? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message")
: NSLocalizedString("This code has already been used.", comment: "alert message")
)
}
UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN)
return .redeemed
}
} catch let error {
logger.error("apiRedeemBadgeCode: \(responseError(error))")
return .refused(message: redeemErrorText(error, purchase: false))
}
}
func showCannotRedeemAlert(_ message: String) {
showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message)
}
// a hidden profile is named only while it is the active one, which is unlocked and on screen
func showBadgeAddedAlert(_ user: any UserLike) {
guard let owner = ChatModel.shared.getUser(user.userId), active(owner) || !owner.hidden else { return }
showAlert(
NSLocalizedString("Badge added", comment: "alert title"),
message: String.localizedStringWithFormat(NSLocalizedString("The badge was added to the profile %@.", comment: "alert message"), owner.displayName)
)
}
struct BadgesRedeemCodeView: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatModel: ChatModel
@AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false
@Environment(\.dismiss) var dismiss: DismissAction
@State private var code = ""
@State private var canonicalCode: String? = nil
@@ -184,39 +226,217 @@ struct BadgesRedeemCodeView: View {
guard let sending = canonicalCode, let user = chatModel.currentUser else { return }
submitting = true
Task {
do {
guard let redeemed = try await apiRedeemBadgeCode(user.userId, sending) else {
await MainActor.run { submitting = false }
return
}
await MainActor.run {
submitting = false
// set before dismissing: BadgesView then switches Support to Your Badge while this screen
// still covers it, so the pop lands on Your Badge instead of showing the switch
BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState)
chatModel.updateUser(redeemed.user)
if let badgeState = redeemed.badgeState, !badgeState.shown {
// a replay adds no purchase; a fresh code's badge can be retired on arrival
let message = redeemed.newBadge
? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message")
: NSLocalizedString("This code has already been used.", comment: "alert message")
showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message)
} else {
supporterBannerShown = true
dismiss()
}
}
} catch let error {
logger.error("apiRedeemBadgeCode: \(responseError(error))")
await MainActor.run {
submitting = false
showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: redeemErrorText(error))
let outcome = await redeemBadgeCode(user, sending)
await MainActor.run {
submitting = false
switch outcome {
case .redeemed: dismiss()
case let .refused(message): showCannotRedeemAlert(message)
case .cancelled: break
}
}
}
}
}
private weak var badgeLinkSheet: UIViewController?
// the open screen's own state, not a copy of its step, so the two cannot disagree
private weak var badgeLinkState: BadgeLinkState?
func isBadgeLinkIssuing() -> Bool {
badgeLinkSheet?.presentingViewController != nil && badgeLinkState?.step == .issuing
}
func openBadgeLink(_ codeText: String) {
guard let code = parseBadgeCode(codeText) else {
return showCannotRedeemAlert(NSLocalizedString("This code is not valid.", comment: "alert message"))
}
let state = BadgeLinkState()
badgeLinkSheet = showAppSheet {
NavigationView {
BadgesRedeemLinkView(code: code, state: state)
.modifier(ThemedBackground())
}
}
badgeLinkState = state
}
enum BadgeLinkStep {
case confirming
case issuing
case redeemed
case viewingBadge
}
final class BadgeLinkState: ObservableObject {
@Published var step = BadgeLinkStep.confirming
}
// Any web page can send a badge link, and a profile holds one badge at a time,
// so this screen asks before redeeming, names the profile, and offers nothing but the redemption.
// It is a code redemption with the code hidden, so it inherits the redeem screen's behaviour: an
// interrupted request is not resumed, and the code stays on the page that issued the link.
struct BadgesRedeemLinkView: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatModel: ChatModel
@ObservedObject private var badgeModel = BadgeModel.shared
let code: String
@ObservedObject var state: BadgeLinkState
var body: some View {
switch state.step {
case .confirming: if profileHasBadge { badgeHeld() } else { confirming() }
case .issuing: beingIssued()
case .redeemed, .viewingBadge: BadgesView(showsAsSheet: true)
}
}
// read for the profile the screen names, when it renders, so the two cannot disagree;
// core refuses a code while the profile shows a badge
private var profileHasBadge: Bool {
badgeModel.userId == chatModel.currentUser?.userId && badgeModel.badgeState?.shown == true
}
// the screen cannot tell a repeated link for the badge it shows from a code for another badge,
// so it asserts neither
private func badgeHeld() -> some View {
linkStep(
"Profile already has a badge",
primary: ("View your badge", { state.step = .viewingBadge }),
textButton: ("Cancel", { closeIfShowing() })
) {
linkText(NSLocalizedString("A badge can be added to another profile, or here once this badge ends.", comment: "badge link, profile has a badge"))
linkText(NSLocalizedString("Its code is on the page you bought it on, under Show code.", comment: "badge link, profile has a badge"))
}
}
private func confirming() -> some View {
linkStep(
"Add badge to your profile?",
primary: ("Add badge", { redeemFromLink() }),
textButton: ("Cancel", { closeIfShowing() })
) {
linkText(String.localizedStringWithFormat(NSLocalizedString("The badge will be added to the profile %@.", comment: "badge link confirmation"), chatModel.currentUser?.displayName ?? ""))
}
}
// leaving does not cancel: the badge is still added after the screen closes
private func beingIssued() -> some View {
linkStep("Issuing your badge", textButton: ("Dismiss", { closeIfShowing() })) {
linkText(NSLocalizedString("The badge will be added to this profile.", comment: "badge link, being issued"))
Spacer()
ProgressView().scaleEffect(2)
}
}
// with no primary, the text button takes its place, so a button does not move between steps
private func linkStep<Content: View>(
_ title: LocalizedStringKey,
primary: (label: LocalizedStringKey, action: () -> Void)? = nil,
textButton: (label: LocalizedStringKey, action: () -> Void),
@ViewBuilder content: () -> Content
) -> some View {
let textButtonLabel = Text(textButton.label)
.font(.body)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
return VStack(alignment: .center, spacing: 16) {
Text(title)
.font(.largeTitle)
.bold()
.foregroundColor(theme.colors.primary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
content()
Spacer()
VStack(spacing: 10) {
if let primary {
Button {
primary.action()
} label: {
Text(primary.label)
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
.padding(.vertical, 10)
Button {
textButton.action()
} label: {
textButtonLabel
}
.frame(height: 22)
} else {
Button {
textButton.action()
} label: {
// the inset OnboardingButtonStyle gives a primary
textButtonLabel.padding()
}
.padding(.vertical, 10)
Color.clear
.frame(height: 22)
}
}
}
.padding(.horizontal, 25)
.padding(.top, 48)
.padding(.bottom, 20)
.frame(maxHeight: .infinity)
.navigationBarTitleDisplayMode(.inline)
}
private func linkText(_ text: String) -> some View {
Text(text)
.font(.body)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
private func redeemFromLink() {
// a second tap before the screen changes must not send the code again
guard state.step == .confirming else { return }
guard let user = chatModel.currentUser else { return closeIfShowing() }
state.step = .issuing
Task {
let outcome = await redeemBadgeCode(user, code)
await MainActor.run {
switch outcome {
case .redeemed:
if !isShowing { showBadgeAddedAlert(user) }
// a covered screen is not left on the spinner; a closed one is not written to
if isOpen { state.step = .redeemed }
case let .refused(message): closeIfShowing { showCannotRedeemAlert(message) }
case .cancelled: closeIfShowing()
}
}
}
}
private var isOpen: Bool {
badgeLinkState === state && badgeLinkSheet?.presentingViewController != nil
}
// an alert over this screen still counts: dismissing from the presenter dismisses it too
private var isShowing: Bool {
guard isOpen, let sheet = badgeLinkSheet else { return false }
return sheet.presentedViewController == nil || sheet.presentedViewController is UIAlertController
}
// An outcome can arrive after this screen was closed or covered: it must not close another screen,
// and a covered one goes back to asking, so it is never left locked on the spinner.
private func closeIfShowing(then: @escaping () -> Void = {}) {
if isShowing, let presenter = badgeLinkSheet?.presentingViewController {
presenter.dismiss(animated: true, completion: then)
} else {
if isOpen { state.step = .confirming }
then()
}
}
}
struct BadgesRedeemCodeView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
@@ -11,13 +11,14 @@ import SimpleXChat
struct BadgesSupportSimplexView: View {
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatModel: ChatModel
@ObservedObject private var store = BadgeStore.shared
// set true when presented as a sheet root (from the chat-list banner) — that path doesn't
// reserve nav-bar space like a NavigationLink push does, so the title lands too close to the top
var showsAsSheet: Bool = false
@State private var whyBuiltActive = false
@State private var howItWorksActive = false
@State private var chooseLevelActive = false
@State private var redeemCodeActive = false
var body: some View {
GeometryReader { g in
@@ -34,8 +35,6 @@ struct BadgesSupportSimplexView: View {
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
// TODO [badges] restore whyBuiltButton() when in-app purchase lands: the level screen
// returns to the flow and howItWorksButton() moves there, leaving this one alone here.
howItWorksButton()
Spacer(minLength: 0)
@@ -46,11 +45,27 @@ struct BadgesSupportSimplexView: View {
Spacer(minLength: 0)
// In-app purchase is not released: the badge is chosen on the site, and the code
// redeemed here. Revert this commit to restore the block below.
// VStack(spacing: 10) {
// chooseLevelButton()
// .padding(.vertical, 10)
// Group {
// if badgeBrowserAllowed {
// buyInBrowserButton()
// } else {
// RedeemCodeButton()
// }
// }
// .frame(height: 22)
// }
VStack(spacing: 10) {
redeemCodeButton()
chooseBadgeOnSiteButton()
.padding(.vertical, 10)
getCodeButton()
.frame(height: 22)
Group {
RedeemCodeButton()
}
.frame(height: 22)
}
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
@@ -65,18 +80,19 @@ struct BadgesSupportSimplexView: View {
.navigationBarTitleDisplayMode(.inline)
}
// the in-app purchase path, kept compiling and uncalled until payments return after the MVP
private func chooseLevelButton() -> some View {
ZStack {
let disabled = !store.canBuy(chatModel.currentUser?.userId)
return ZStack {
Button {
chooseLevelActive = true
} label: {
Text("Choose your level")
Text("Choose your badge")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
.buttonStyle(OnboardingButtonStyle(isDisabled: disabled))
.disabled(disabled)
NavigationLink(isActive: $chooseLevelActive) {
BadgesYourLevelView()
BadgesChooseBadgeView()
.modifier(ThemedBackground())
} label: {
EmptyView()
@@ -125,14 +141,42 @@ struct BadgesSupportSimplexView: View {
}
}
private func redeemCodeButton() -> some View {
// In-app purchase is not released: delete with the commented block above to restore it.
private func chooseBadgeOnSiteButton() -> some View {
Button {
UIApplication.shared.open(URL(string: badgePageUrl)!)
} label: {
Text("Choose your badge")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
}
private func buyInBrowserButton() -> some View {
Button {
UIApplication.shared.open(URL(string: badgePageUrl)!)
} label: {
Text("Buy in browser")
.font(.body)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
}
}
}
struct RedeemCodeButton: View {
@EnvironmentObject var theme: AppTheme
@State private var redeemCodeActive = false
var body: some View {
ZStack {
Button {
redeemCodeActive = true
} label: {
Text("Redeem badge code")
.font(.body)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
NavigationLink(isActive: $redeemCodeActive) {
BadgesRedeemCodeView()
@@ -144,17 +188,6 @@ struct BadgesSupportSimplexView: View {
.hidden()
}
}
private func getCodeButton() -> some View {
Button {
openExternalLink(URL(string: "https://simplex.chat/badges/")!)
} label: {
Text("Get your code")
.font(.body)
.fontWeight(.medium)
.foregroundColor(theme.colors.primary)
}
}
}
struct PhoneSupporterHero: View {
@@ -12,6 +12,7 @@ import SimpleXChat
struct BadgesView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject private var badgeModel = BadgeModel.shared
@ObservedObject private var store = BadgeStore.shared
var showsAsSheet: Bool = false
private var shownBadge: BadgeState? {
@@ -25,15 +26,28 @@ struct BadgesView: View {
if let badgeState = shownBadge {
BadgesYourBadgeView(badgeState: badgeState, showsAsSheet: showsAsSheet)
.transition(.opacity)
} else if let purchaseState = store.purchaseState(chatModel.currentUser?.userId) {
// holds the purchase screens' slot, so a consumable cannot be bought twice
BadgesPurchaseStateView(title: purchaseState.title, message: purchaseState.message, failure: store.creditError(chatModel.currentUser?.userId), showsAsSheet: showsAsSheet)
.transition(.opacity)
} else if store.checkingPurchases {
BadgesPurchaseStateView(title: "Checking your purchases", showsAsSheet: showsAsSheet)
.transition(.opacity)
} else {
BadgesSupportSimplexView(showsAsSheet: showsAsSheet)
.transition(.opacity)
}
}
.animation(.default, value: shownBadge != nil)
.animation(.default, value: store.purchaseState(chatModel.currentUser?.userId))
.animation(.default, value: store.checkingPurchases)
.onAppear { badgesViewShown = true }
.onDisappear { badgesViewShown = false }
}
}
private(set) var badgesViewShown = false
var supportSimpleXAlertAction: UIAlertAction {
UIAlertAction(title: NSLocalizedString("Support SimpleX", comment: "alert button"), style: .default) { _ in
openBadgesView()
+26 -12
View File
@@ -161,10 +161,10 @@ struct ActiveCallView: View {
CallSoundsPlayer.shared.stop()
}
}
case let .ice(iceCandidates):
case let .ice(iceCandidates, session):
Task {
do {
try await apiSendCallExtraInfo(call.contact, iceCandidates)
try await apiSendCallExtraInfo(call.contact, session, iceCandidates)
} catch {
logger.error("apiSendCallExtraInfo \(responseError(error))")
}
@@ -172,15 +172,24 @@ struct ActiveCallView: View {
case let .connection(state):
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
case .connected = callStatus {
call.direction == .outgoing
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
// connectedAt is kept on reconnection, so that call duration is not reset
if call.connectedAt == nil {
call.direction == .outgoing
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
call.connectedAt = .now
}
call.callState = .connected
call.connectedAt = .now
CallSoundsPlayer.shared.stop()
if !wasConnected {
CallSoundsPlayer.shared.vibrate(long: false)
wasConnected = true
}
} else if state.connectionState == "reconnecting" {
if call.callState != .reconnecting {
CallSoundsPlayer.shared.startConnectingCallSound()
}
call.callState = .reconnecting
}
if state.connectionState == "closed" {
closeCallView(client)
@@ -190,17 +199,22 @@ struct ActiveCallView: View {
m.activeCall = nil
m.activeCallViewIsCollapsed = false
}
Task {
do {
try await apiCallStatus(call.contact, state.connectionState)
} catch {
logger.error("apiCallStatus \(responseError(error))")
// "reconnecting" is not a core call status
if state.connectionState != "reconnecting" {
Task {
do {
try await apiCallStatus(call.contact, state.connectionState)
} catch {
logger.error("apiCallStatus \(responseError(error))")
}
}
}
case let .connected(connectionInfo):
call.callState = .connected
call.connectionInfo = connectionInfo
call.connectedAt = .now
if call.connectedAt == nil {
call.connectedAt = .now
}
if !wasConnected {
CallSoundsPlayer.shared.vibrate(long: false)
wasConnected = true
+14 -6
View File
@@ -77,6 +77,7 @@ enum CallState {
case answerReceived // incoming - webrtc answer received via API
case negotiated // outgoing - webrtc offer processed and answer sent, incoming - webrtc answer processed
case connected
case reconnecting
case ended
var text: LocalizedStringKey {
@@ -89,6 +90,7 @@ enum CallState {
case .answerReceived: return "received confirmation…"
case .negotiated: return "connecting…"
case .connected: return "connected"
case .reconnecting: return "reconnecting…"
case .ended: return "ended"
}
}
@@ -119,7 +121,7 @@ enum WCallCommand: Equatable, Encodable, Decodable {
case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
case answer(answer: String, iceCandidates: String)
case ice(iceCandidates: String)
case ice(iceCandidates: String, session: String? = nil)
case media(source: CallMediaSource, enable: Bool)
case end
@@ -131,6 +133,7 @@ enum WCallCommand: Equatable, Encodable, Decodable {
case offer
case answer
case iceCandidates
case session
case enable
case iceServers
case relay
@@ -174,9 +177,10 @@ enum WCallCommand: Equatable, Encodable, Decodable {
try container.encode("answer", forKey: .type)
try container.encode(answer, forKey: .answer)
try container.encode(iceCandidates, forKey: .iceCandidates)
case let .ice(iceCandidates):
case let .ice(iceCandidates, session):
try container.encode("ice", forKey: .type)
try container.encode(iceCandidates, forKey: .iceCandidates)
try container.encodeIfPresent(session, forKey: .session)
case let .media(source, enable):
try container.encode("media", forKey: .type)
try container.encode(source, forKey: .media)
@@ -213,7 +217,8 @@ enum WCallCommand: Equatable, Encodable, Decodable {
self = .answer(answer: answer, iceCandidates: iceCandidates)
case "ice":
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .ice(iceCandidates: iceCandidates)
let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session)
self = .ice(iceCandidates: iceCandidates, session: session)
case "media":
let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
@@ -231,7 +236,7 @@ enum WCallResponse: Equatable, Decodable {
case capabilities(capabilities: CallCapabilities)
case offer(offer: String, iceCandidates: String, capabilities: CallCapabilities)
case answer(answer: String, iceCandidates: String)
case ice(iceCandidates: String)
case ice(iceCandidates: String, session: String? = nil)
case connection(state: ConnectionState)
case connected(connectionInfo: ConnectionInfo)
case peerMedia(source: CallMediaSource, enabled: Bool)
@@ -246,6 +251,7 @@ enum WCallResponse: Equatable, Decodable {
case offer
case answer
case iceCandidates
case session
case state
case connectionInfo
case message
@@ -290,7 +296,8 @@ enum WCallResponse: Equatable, Decodable {
self = .answer(answer: answer, iceCandidates: iceCandidates)
case "ice":
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
self = .ice(iceCandidates: iceCandidates)
let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session)
self = .ice(iceCandidates: iceCandidates, session: session)
case "connection":
let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state)
self = .connection(state: state)
@@ -333,9 +340,10 @@ extension WCallResponse: Encodable {
try container.encode("answer", forKey: .type)
try container.encode(answer, forKey: .answer)
try container.encode(iceCandidates, forKey: .iceCandidates)
case let .ice(iceCandidates):
case let .ice(iceCandidates, session):
try container.encode("ice", forKey: .type)
try container.encode(iceCandidates, forKey: .iceCandidates)
try container.encodeIfPresent(session, forKey: .session)
case let .connection(state):
try container.encode("connection", forKey: .type)
try container.encode(state, forKey: .state)
+135 -28
View File
@@ -20,6 +20,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}()
private static let ivTagBytes: Int = 28
private static let enableEncryption: Bool = true
private static let reconnectTimeout: UInt64 = 30_000_000_000
private static let iceRestartInterval: UInt64 = 10_000_000_000
private var chat_ctrl = getChatCtrl()
struct Call {
@@ -37,6 +39,9 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
var frameEncryptor: RTCFrameEncryptor?
var frameDecryptor: RTCFrameDecryptor?
var peerHasOldVersion: Bool
var wasConnected = false
var reconnection: Task<Void, Never>? = nil
var iceRestarts: Task<Void, Never>? = nil
}
struct NotConnectedCall {
@@ -257,11 +262,15 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
resp = .error(message: "answer: remote description is not set")
}
}
case let .ice(iceCandidates):
case let .ice(iceCandidates, session):
if let pc = pc,
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
addIceCandidates(pc, remoteIceCandidates)
resp = .ok
if let session {
resp = await receiveRestartSession(pc, session, remoteIceCandidates)
} else {
addIceCandidates(pc, remoteIceCandidates)
resp = .ok
}
} else {
resp = .error(message: "ice: call not started")
}
@@ -301,6 +310,101 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
}
}
private func startReconnecting(_ connection: RTCPeerConnection) async {
activeCall?.reconnection = Task {
_ = try? await Task.sleep(nanoseconds: WebRTCClient.reconnectTimeout)
if Task.isCancelled { return }
await sendConnectionState(connection, "disconnected")
endCall()
}
// Only the party that sent the initial offer restarts ICE, so that restart offers never collide
if connection.localDescription?.type == .offer {
activeCall?.iceRestarts = Task {
while !Task.isCancelled {
await restartIce(connection)
_ = try? await Task.sleep(nanoseconds: WebRTCClient.iceRestartInterval)
}
}
}
await sendConnectionState(connection, "reconnecting")
}
private func stopReconnecting() {
activeCall?.reconnection?.cancel()
activeCall?.reconnection = nil
activeCall?.iceRestarts?.cancel()
activeCall?.iceRestarts = nil
}
private func restartIce(_ connection: RTCPeerConnection) async {
// the previous restart offer is not answered yet
if connection.signalingState != .stable { return }
let (offer, error) = await connection.offer(iceRestart: true)
if let offer {
await sendRestartSession(offer)
} else {
logger.error("WebRTCClient: ICE restart error: \(error?.localizedDescription ?? "unknown error")")
}
}
private func receiveRestartSession(_ pc: RTCPeerConnection, _ session: String, _ remoteIceCandidates: [RTCIceCandidate]) async -> WCallResponse {
guard let desc: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: session)),
let type = desc.type, let sdp = desc.sdp else {
return .error(message: "ice: invalid session")
}
let remoteDesc = RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp)
if type == .offer && pc.localDescription?.type != .offer {
if (try? await pc.setRemoteDescription(remoteDesc)) == nil {
return .error(message: "ice: remote offer is not set")
}
let (answer, error) = await pc.answer()
guard let answer else {
return .error(message: "ice: answer error: \(error?.localizedDescription ?? "unknown error")")
}
addIceCandidates(pc, remoteIceCandidates)
await sendRestartSession(answer)
} else if type == .answer && pc.signalingState == .haveLocalOffer {
if (try? await pc.setRemoteDescription(remoteDesc)) == nil {
return .error(message: "ice: remote answer is not set")
}
addIceCandidates(pc, remoteIceCandidates)
} else {
return .error(message: "ice: unexpected ICE restart \(type.rawValue)")
}
return .ok
}
private func sendRestartSession(_ desc: RTCSessionDescription) async {
await sendCallResponse(.init(
corrId: nil,
resp: .ice(
iceCandidates: compressToBase64(input: encodeJSON(await getInitialIceCandidates())),
session: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: desc.type.toSdpType(), sdp: desc.sdp)))
),
command: nil)
)
waitForMoreIceCandidates()
}
private func sendConnectionState(_ connection: RTCPeerConnection, _ connectionState: String) async {
guard let iceConnectionState = connection.iceConnectionState.toString(),
let iceGatheringState = connection.iceGatheringState.toString(),
let signalingState = connection.signalingState.toString()
else {
return
}
await sendCallResponse(.init(
corrId: nil,
resp: .connection(state: ConnectionState(
connectionState: connectionState,
iceConnectionState: iceConnectionState,
iceGatheringState: iceGatheringState,
signalingState: signalingState)
),
command: nil)
)
}
// Spec: spec/services/calls.md#sendIceCandidates
func sendIceCandidates(_ candidates: [RTCIceCandidate]) async {
await self.sendCallResponse(.init(
@@ -657,6 +761,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
(notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
guard let call = activeCall else { return }
logger.debug("WebRTCClient: ending the call")
call.reconnection?.cancel()
call.iceRestarts?.cancel()
call.connection.close()
call.connection.delegate = nil
call.frameEncryptor?.delegate = nil
@@ -678,16 +784,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
extension WebRTC.RTCPeerConnection {
func mediaConstraints() -> RTCMediaConstraints {
RTCMediaConstraints(
mandatoryConstraints: [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue,
kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue],
optionalConstraints: nil)
func mediaConstraints(iceRestart: Bool = false) -> RTCMediaConstraints {
var constraints = [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue,
kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue]
if iceRestart {
constraints[kRTCMediaConstraintsIceRestart] = kRTCMediaConstraintsValueTrue
}
return RTCMediaConstraints(mandatoryConstraints: constraints, optionalConstraints: nil)
}
func offer() async -> (RTCSessionDescription?, Error?) {
func offer(iceRestart: Bool = false) async -> (RTCSessionDescription?, Error?) {
await withCheckedContinuation { cont in
offer(for: mediaConstraints()) { (sdp, error) in
offer(for: mediaConstraints(iceRestart: iceRestart)) { (sdp, error) in
self.processSDP(cont, sdp, error)
}
}
@@ -766,24 +874,20 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)")
guard let connectionStateString = newState.toString(),
let iceConnectionStateString = connection.iceConnectionState.toString(),
let iceGatheringStateString = connection.iceGatheringState.toString(),
let signalingStateString = connection.signalingState.toString()
else {
return
}
guard let connectionStateString = newState.toString() else { return }
Task {
await sendCallResponse(.init(
corrId: nil,
resp: .connection(state: ConnectionState(
connectionState: connectionStateString,
iceConnectionState: iceConnectionStateString,
iceGatheringState: iceGatheringStateString,
signalingState: signalingStateString)
),
command: nil)
)
if activeCall?.wasConnected == true {
switch newState {
case .disconnected, .failed:
if activeCall?.reconnection == nil {
await startReconnecting(connection)
}
return
case .new, .checking: return
default: ()
}
}
await sendConnectionState(connection, connectionStateString)
switch newState {
case .checking:
@@ -792,7 +896,10 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
}
let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true
setSpeakerEnabledAndConfigureSession(enableSpeaker)
case .connected: sendConnectedEvent(connection)
case .connected:
stopReconnecting()
activeCall?.wasConnected = true
sendConnectedEvent(connection)
case .disconnected, .failed: endCall()
default: ()
}
@@ -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)
)
@@ -26,7 +26,7 @@ struct CIFileView: View {
var body: some View {
if smallViewSize != nil {
fileIndicator()
.simultaneousGesture(TapGesture().onEnded(fileAction))
.simultaneousGesture(TapGesture().onEnded { fileAction(file) })
} else {
// reserve exact space for the overlaid meta (timestamp + all icons), rendered transparently - matches MsgContentView
let encrypted: Bool? = if let fileSource = file?.fileSource { fileSource.cryptoArgs != nil } else { nil }
@@ -56,7 +56,7 @@ struct CIFileView: View {
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
.simultaneousGesture(TapGesture().onEnded(fileAction))
.simultaneousGesture(TapGesture().onEnded { fileAction(file) })
.disabled(!itemInteractive)
}
}
@@ -85,7 +85,7 @@ struct CIFileView: View {
return false
}
private func fileAction() {
private func fileAction(_ file: CIFile?) {
logger.debug("CIFileView fileAction")
if let file = file {
switch (file.fileStatus) {
@@ -93,6 +93,7 @@ struct CIFileView: View {
if let prohibited = file.fileProhibited {
showProhibitedFileAlert(file, prohibited)
} else {
m.openWhenReceived(file) { fileAction($0) }
Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task")
if let user = m.currentUser {
@@ -117,7 +118,7 @@ struct CIFileView: View {
case .rcvComplete:
logger.debug("CIFileView fileAction - in .rcvComplete")
if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
openFile(file, fileSource)
}
case let .rcvError(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvError")
@@ -128,12 +129,12 @@ struct CIFileView: View {
case .sndStored:
logger.debug("CIFileView fileAction - in .sndStored")
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
openFile(file, fileSource)
}
case .sndComplete:
logger.debug("CIFileView fileAction - in .sndComplete")
if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
openFile(file, fileSource)
}
case let .sndError(sndFileError):
logger.debug("CIFileView fileAction - in .sndError")
@@ -146,6 +147,14 @@ struct CIFileView: View {
}
}
private func openFile(_ file: CIFile, _ fileSource: CryptoFile) {
if isMarkdownFile(file) {
showMarkdownFile(file)
} else {
saveCryptoFile(fileSource)
}
}
@ViewBuilder private func fileIndicator() -> some View {
if let file = file {
switch file.fileStatus {
@@ -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
)
@@ -54,6 +54,7 @@ struct CIImageView: View {
if let prohibited = file.fileProhibited {
showProhibitedFileAlert(file, prohibited)
} else {
m.openWhenReceived(file) { _ in showFullScreenImage = true }
Task {
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
@@ -52,7 +52,7 @@ struct CILinkView: View {
func openBrowserAlert(uri: String) {
let (url, err) = sanitizeUri(uri)
if let url {
let uriStr = url.uri.absoluteString
let uriStr = displayedUri(url.uri.absoluteString)
showAlert(
NSLocalizedString("Open link?", comment: "alert title"),
message: uriStr.count > 160 ? "\(uriStr.prefix(160))…" : uriStr,
@@ -90,9 +90,9 @@ func openBrowserAlert(uri: String) {
func showInvalidLinkAlert(_ uri: String, error: String? = nil) {
let message = if let error, !error.isEmpty {
error + "\n" + uri
error + "\n" + displayedUri(uri)
} else {
uri
displayedUri(uri)
}
showAlert(
NSLocalizedString("Invalid link", comment: "alert title"),
@@ -101,6 +101,25 @@ func showInvalidLinkAlert(_ uri: String, error: String? = nil) {
)
}
func unicodeMarker(_ s: Unicode.Scalar) -> String {
String(format: "[U+%04X]", s.value)
}
func displayedUri(_ uri: String) -> String {
let authorityStart = uri.range(of: "://")?.upperBound ?? uri.startIndex
let authority = authorityStart ..< (uri[authorityStart...].firstIndex(where: { "/?#".contains($0) }) ?? uri.endIndex)
var shown = ""
for i in uri.unicodeScalars.indices {
let s = uri.unicodeScalars[i]
if s.properties.generalCategory == .format || (!s.isASCII && authority.contains(i)) {
shown += unicodeMarker(s)
} else {
shown.unicodeScalars.append(s)
}
}
return shown
}
func sanitizeUri(_ s: String) -> (url: (uri: URL, sanitizedUri: URL?)?, error: String?) {
let parsed = parseSanitizeUri(s, safe: false)
return if let uri = URL(string: s), let uriInfo = parsed?.uriInfo {
@@ -120,6 +120,25 @@ struct CIVideoView: View {
}
}
private func playReceived(_ file: CIFile) {
setupPlayer(file)
if urlDecrypted == nil {
decrypt(file: file, completed: playVideo)
} else {
playVideo()
}
}
private func playVideo() {
if smallView {
showFullScreenPlayer = urlDecrypted != nil
} else if let urlDecrypted {
m.stopPreviousRecPlay = urlDecrypted
videoPlaying = true
player?.play()
}
}
private func setupPlayer(_ file: CIFile?) {
let newUrl = getLoadedVideo(file)
if newUrl == url {
@@ -424,6 +443,7 @@ struct CIVideoView: View {
if let prohibited = file.fileProhibited {
showProhibitedFileAlert(file, prohibited)
} else {
m.openWhenReceived(file, open: playReceived)
Task {
if let user = m.currentUser {
await receiveFile(user, file.fileId, false, false)
@@ -303,6 +303,11 @@ struct VoiceMessagePlayer: View {
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
playPauseIcon(icon, theme.colors.primary)
.simultaneousGesture(TapGesture().onEnded {
chatModel.openWhenReceived(recordingFile) { file in
if let recordingSource = getLoadedFileSource(file) {
startPlayback(recordingSource)
}
}
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
@@ -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)
@@ -0,0 +1,215 @@
import SwiftUI
import SimpleXChat
import Ink
private let MAX_MARKDOWN_VIEW_SIZE: Int64 = 1_048_576
private let MAX_MARKDOWN_LINE_LENGTH = 15_000
private let MAX_MARKDOWN_NESTING = 16
private let MAX_MARKDOWN_LINK_NESTING = 4
private let MAX_MARKDOWN_TABLE_ROWS = 200
private let MAX_MARKDOWN_IMAGE_DIMENSION: CGFloat = 4320
private let MAX_MARKDOWN_IMAGE_PIXELS: CGFloat = 2160 * 4320
func isMarkdownFile(_ file: CIFile) -> Bool {
let ext = (file.fileName as NSString).pathExtension.lowercased()
return file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && (ext == "md" || ext == "markdown")
}
func showMarkdownFile(_ file: CIFile) {
guard let fileSource = getLoadedFileSource(file) else { return }
Task {
do {
let data = try getFileData(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs)
let text = decodedText(data)
let lines = text.components(separatedBy: "\n")
let html = withinMarkdownLimits(lines)
? MarkdownParser(modifiers: [Modifier(target: .html) { escapedHTML($0.markdown) }, imagesWithinBudget()]).html(from: text)
: nil
let shownLines = lines.flatMap(chunkedLine)
let hasBidiControls = text.unicodeScalars.contains(where: isBidiControl)
await MainActor.run {
showAppSheet {
MarkdownFileView(file: file, fileSource: fileSource, lines: shownLines, html: html, hasBidiControls: hasBidiControls)
}
}
} catch {
await MainActor.run {
showAlert(NSLocalizedString("Error reading file", comment: "alert title"), message: error.localizedDescription)
}
}
}
}
private func decodedText(_ data: Data) -> String {
var text = String.UnicodeScalarView()
var parser = Unicode.UTF8.ForwardParser()
var bytes = data.makeIterator()
while true {
switch parser.parseScalar(from: &bytes) {
case let .valid(encoded): text.append(replacingControlChar(Unicode.UTF8.decode(encoded)))
case let .error(length): text.append(contentsOf: repeatElement("?" as Unicode.Scalar, count: length))
case .emptyInput: return String(text).replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
}
}
}
private func replacingControlChar(_ s: Unicode.Scalar) -> Unicode.Scalar {
s.value < 0x20 && s != "\t" && s != "\n" && s != "\r" ? "?" : s
}
private func withinMarkdownLimits(_ lines: [String]) -> Bool {
var tableRows = 0
var linkNesting = 0
for line in lines {
tableRows = line.contains("|") ? tableRows + 1 : 0
if line.count > MAX_MARKDOWN_LINE_LENGTH || nestingLevel(line) > MAX_MARKDOWN_NESTING || tableRows > MAX_MARKDOWN_TABLE_ROWS || !linkNestingWithinLimit(line, &linkNesting) {
return false
}
}
return true
}
private func linkNestingWithinLimit(_ line: String, _ nesting: inout Int) -> Bool {
if line.isEmpty { nesting = 0 }
for c in line {
if c == "[" {
nesting += 1
if nesting > MAX_MARKDOWN_LINK_NESTING { return false }
} else if c == "]" && nesting > 0 {
nesting -= 1
}
}
return true
}
private func nestingLevel(_ line: String) -> Int {
let content = line.drop(while: { $0 == " " || $0 == "\t" || $0 == ">" })
let prefix = line[..<content.startIndex]
let quotes = prefix.filter { $0 == ">" }.count
let listItem = content.first.map { "-*+".contains($0) || $0.isNumber } ?? false
return quotes + (listItem ? (prefix.count - quotes) / 2 : 0)
}
private func chunkedLine(_ line: String) -> [String] {
let chars = Array(line)
return stride(from: 0, to: max(chars.count, 1), by: MAX_MARKDOWN_LINE_LENGTH).map {
String(chars[$0 ..< min($0 + MAX_MARKDOWN_LINE_LENGTH, chars.count)])
}
}
private func escapedHTML(_ s: Substring) -> String {
s.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
private func imagesWithinBudget() -> Modifier {
var pixels = MAX_MARKDOWN_IMAGE_PIXELS
return Modifier(target: .images) { image in
let src = image.html.dropFirst("<img src=\"".count).prefix(while: { $0 != "\"" })
guard src.hasPrefix("data:image/") else { return escapedHTML(image.markdown) }
let parts = src.split(separator: ",", maxSplits: 1)
guard parts.count == 2,
parts[0].hasSuffix(";base64"),
let data = Data(base64Encoded: String(parts[1])),
let size = UIImage(data: data)?.size,
size.width <= MAX_MARKDOWN_IMAGE_DIMENSION,
size.height <= MAX_MARKDOWN_IMAGE_DIMENSION,
size.width * size.height <= pixels
else { return "" }
pixels -= size.width * size.height
return "<img src=\"\(src)\"/>"
}
}
private func isBidiControl(_ s: Unicode.Scalar) -> Bool {
[0x061C, 0x200E, 0x200F].contains(s.value) || (0x202A...0x202E).contains(s.value) || (0x2066...0x2069).contains(s.value)
}
private func withInvisibleCharMarkers(_ line: String) -> Text {
var text = Text(verbatim: "")
var plain = ""
for s in line.unicodeScalars {
if s.properties.generalCategory == .format {
text = text + Text(verbatim: plain) + Text(verbatim: unicodeMarker(s)).foregroundColor(.orange)
plain = ""
} else {
plain.unicodeScalars.append(s)
}
}
return text + Text(verbatim: plain)
}
private func openMarkdownFileLink(_ url: URL) {
let uri = url.absoluteString
let parsed = parseSimpleXMarkdown(uri)
let format = parsed?.count == 1 ? parsed?.first?.format : nil
switch format {
case let .simplexLink(_, _, simplexUri, _)?:
if let simplexUrl = URL(string: simplexUri) {
ChatModel.shared.appOpenUrl = simplexUrl
} else {
showInvalidLinkAlert(uri)
}
case .uri? where uri.hasPrefix("https://") || uri.hasPrefix("http://"):
openBrowserAlert(uri: uri)
default:
showInvalidLinkAlert(uri)
}
}
struct MarkdownFileView: View {
@AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW) private var textView = false
let file: CIFile
let fileSource: CryptoFile
let lines: [String]
let html: String?
let hasBidiControls: Bool
var body: some View {
NavigationView {
VStack(spacing: 0) {
if hasBidiControls {
Text("This file has text direction characters. The text can be shown in a different order than it is written.")
.foregroundColor(.orange)
.padding()
}
if let html, !textView {
ConditionsWebView(html: html, openLink: openMarkdownFileLink)
.padding(.horizontal)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(lines.indices, id: \.self) { i in
withInvisibleCharMarkers(lines[i])
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
}
.padding()
}
}
}
.modifier(ThemedBackground())
.navigationTitle(file.fileName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if html != nil {
Button {
textView.toggle()
} label: {
Image(systemName: textView ? "doc.richtext" : "chevron.left.forwardslash.chevron.right")
}
}
Button {
saveCryptoFile(fileSource)
} label: {
Image(systemName: "square.and.arrow.up")
}
}
}
}
.navigationViewStyle(.stack)
}
}
@@ -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<Int> = []
@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<Int>, _ 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<Int>? = 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<Int>? = 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..<max(start, min(range.upperBound, fts.count))], false)
} else if let markdownBlocks {
let headings = headingSegments(fts.count, markdownBlocks)
var i = 0
while i < fts.count {
var j = i + 1
while j < fts.count && headings[j] == headings[i] { j += 1 }
appendSegments(fts[i..<j], headings[i])
i = j
}
} else {
appendSegments(fts[...], false)
}
} else {
res.append(NSMutableAttributedString(string: text, attributes: plain))
}
return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps)
func segmentFonts(_ heading: Bool) -> (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<FormattedText>, _ 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
@@ -490,12 +596,13 @@ func messageText(
attrs[webLinkAttrKey] = true
handleTaps = true
}
t = displayedUri(t)
case let .hyperLink(text, uri):
attrs = linkAttrs()
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):
@@ -562,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<Int>
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<BlockText: View, MetaLine: View>: 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<Int>, _ 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..<max(r.from, r.to), font, l.secrets[min(max(r.from, 0), formattedText.count)], l.lastIsText && index == l.starts[l.starts.count - 1] - 1)
}
@ViewBuilder private func blockView(_ block: MarkdownBlock, _ start: Int, _ l: BlocksLayout) -> 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..<columnCount, id: \.self) { c in
Group {
if c < row.count {
text(rowStarts[r] + c, row[c], r == 0 ? boldFont(l.font) : l.font, l).fixedSize()
} else {
Text(verbatim: "")
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: cellAlignment(c < columns.count ? columns[c] : .none))
.background(r == 0 ? cardColor : r % 2 == 0 ? Color(uiColor: .secondaryLabel).opacity(0.1) : Color.clear)
.overlay(Rectangle().stroke(lineColor, lineWidth: 0.5))
}
}
}
}
.fixedSize()
.clipShape(RoundedRectangle(cornerRadius: radius))
.overlay(RoundedRectangle(cornerRadius: radius).stroke(lineColor, lineWidth: 1))
}
} else {
tableSourceCard(tableRows, l, radius)
}
} else {
tableSourceCard(tableRows, l, radius)
}
}
private func tableSourceCard(_ tableRows: [[SegmentRange]], _ l: BlocksLayout, _ radius: CGFloat) -> 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..<max(start, min(to, formattedText.count))].contains {
if case .markup(.paragraphBreak) = $0.format { true } else { false }
}
}
private func codeText(_ r: SegmentRange) -> String {
let start = min(max(r.from, 0), formattedText.count)
return formattedText[start..<max(start, min(r.to, formattedText.count))].map {
switch $0.format {
case let .markup(token): token.drawnText
case .escape: ""
default: $0.text
}
}.joined()
}
private func tableSource(_ tableRows: [[SegmentRange]]) -> 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..<max(start, end)].map(\.text).joined()
}
}
private extension MarkdownBlock {
var startIndex: Int {
if case let .rule(mark) = self { mark.from } else { textRanges.first?.range.from ?? 0 }
}
var endIndex: Int {
if case let .rule(mark) = self { mark.to } else { textRanges.last?.range.to ?? 0 }
}
}
private func headingFont(_ font: UIFont, _ level: Int) -> 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<Content: View>: 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<Content: View>: 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))])
}
}
}
}
@@ -607,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() }
}
@@ -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<Int> = []
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))
+7 -4
View File
@@ -1735,6 +1735,7 @@ struct ChatView: View {
@State private var markReadTask: Task<Void, Never>? = 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) }
@@ -1968,7 +1969,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 +2053,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
})
@@ -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)
@@ -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)
@@ -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)
@@ -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 {
@@ -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)
}
@@ -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]?) {
@@ -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)
)
+6 -2
View File
@@ -31,14 +31,18 @@ private struct PrivacySensitive: ViewModifier {
// Presented from the top view controller instead of a .sheet on a parent view, so an alert button or
// a view that is itself in a sheet can open it.
func showAppSheet<Content: View>(@ViewBuilder content: () -> Content) {
@discardableResult
func showAppSheet<Content: View>(@ViewBuilder content: () -> Content) -> UIViewController? {
if let topController = getTopViewController() {
let v = content()
.modifier(PrivacySensitive())
.environmentObject(ChatModel.shared)
.environmentObject(AppTheme.shared)
topController.present(UIHostingController(rootView: v), animated: true)
let sheet = UIHostingController(rootView: v)
topController.present(sheet, animated: true)
return sheet
}
return nil
}
extension View {
@@ -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
)
@@ -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()
@@ -98,7 +98,7 @@ func openExternalLink(_ url: URL) {
} else {
showAlert(
title: NSLocalizedString("Open external link?", comment: "alert title"),
message: s,
message: displayedUri(s),
buttonTitle: NSLocalizedString("Open", comment: "alert button"),
buttonAction: { UIApplication.shared.open(url) },
cancelButton: true
@@ -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)
@@ -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
@@ -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,
@@ -12,11 +12,15 @@ import WebKit
struct ConditionsWebView: UIViewRepresentable {
@State var html: String
var openLink: (URL) -> Void = openExternalLink
@EnvironmentObject var theme: AppTheme
@State var pageLoaded = false
func makeUIView(context: Context) -> WKWebView {
let view = WKWebView()
let config = WKWebViewConfiguration()
config.defaultWebpagePreferences.allowsContentJavaScript = false
config.websiteDataStore = .nonPersistent()
let view = WKWebView(frame: .zero, configuration: config)
view.backgroundColor = .clear
view.isOpaque = false
view.navigationDelegate = context.coordinator
@@ -35,8 +39,9 @@ struct ConditionsWebView: UIViewRepresentable {
}
private func loadPage(_ webView: WKWebView) {
let nonce = UUID().uuidString
let styles = """
<style>
<style nonce="\(nonce)">
body {
color: \(theme.colors.onBackground.toHTMLHex());
font-family: Helvetica;
@@ -48,9 +53,23 @@ struct ConditionsWebView: UIViewRepresentable {
font-family: Menlo;
background: \(theme.colors.secondary.opacity(theme.colors.isLight ? 0.2 : 0.3).toHTMLHex());
}
pre {
overflow-x: auto;
}
table {
border-collapse: collapse;
}
th, td {
border: 1px solid \(theme.colors.secondary.toHTMLHex());
padding: 4px 8px;
}
img {
max-width: 100%;
}
</style>
"""
let head = "<head><meta name='viewport' content='width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=no'>\(styles)</head>"
let csp = "<meta http-equiv='Content-Security-Policy' content=\"default-src 'none'; style-src 'nonce-\(nonce)'; img-src data:; base-uri 'none'; form-action 'none'\">"
let head = "<head>\(csp)<meta name='viewport' content='width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=no'>\(styles)</head>"
webView.loadHTMLString(head + html, baseURL: nil)
DispatchQueue.main.async {
pageLoaded = true
@@ -58,10 +77,16 @@ struct ConditionsWebView: UIViewRepresentable {
}
func makeCoordinator() -> Cordinator {
Cordinator()
Cordinator(openLink: openLink)
}
class Cordinator: NSObject, WKNavigationDelegate {
let openLink: (URL) -> Void
init(openLink: @escaping (URL) -> Void) {
self.openLink = openLink
}
func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
@@ -71,9 +96,9 @@ struct ConditionsWebView: UIViewRepresentable {
switch navigationAction.navigationType {
case .linkActivated:
decisionHandler(.cancel)
openExternalLink(url)
openLink(url)
default:
decisionHandler(.allow)
decisionHandler(url.scheme == "about" ? .allow : .cancel)
}
}
}
@@ -84,6 +84,7 @@ let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage"
let DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView"
let DEFAULT_CURRENT_THEME = "currentTheme"
let DEFAULT_SYSTEM_DARK_THEME = "systemDarkTheme"
@@ -145,6 +146,7 @@ let appDefaults: [String: Any] = [
DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true,
DEFAULT_SHOW_SENT_VIA_RPOXY: false,
DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false,
DEFAULT_MARKDOWN_FILE_TEXT_VIEW: false,
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue,
DEFAULT_THEME_OVERRIDES: "{}",
@@ -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 }
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -4002,7 +4002,7 @@ SimpleX 伺服器並不會看到你的個人檔案。</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Join as %@" xml:space="preserve" approved="no">
<source>join as %@</source>
<source>Join as %@</source>
<target state="translated">以 %@ 身份加入</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -11597,6 +11597,274 @@ If you cancel, the channel will be deleted - you can create it again.</source>
<source>https://</source>
<target state="translated">https://</target>
</trans-unit>
<trans-unit id="The SimpleX name %@ is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner." xml:space="preserve">
<source>The SimpleX name %@ is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner.</source>
</trans-unit>
<trans-unit id="forwarded from" xml:space="preserve">
<source>forwarded from</source>
</trans-unit>
<trans-unit id="Require signing messages." xml:space="preserve">
<source>Require signing messages.</source>
</trans-unit>
<trans-unit id="The SimpleX name %@ is registered, but it has no valid link." xml:space="preserve">
<source>The SimpleX name %@ is registered, but it has no valid link.</source>
</trans-unit>
<trans-unit id="Error sharing address" xml:space="preserve">
<source>Error sharing address</source>
</trans-unit>
<trans-unit id="contributor" xml:space="preserve">
<source>contributor</source>
</trans-unit>
<trans-unit id="SimpleX name sale starts in" xml:space="preserve">
<source>SimpleX name sale starts in</source>
</trans-unit>
<trans-unit id="Update the app to register a SimpleX domain" xml:space="preserve">
<source>Update the app to register a SimpleX domain</source>
</trans-unit>
<trans-unit id="SimpleX name not verified" xml:space="preserve">
<source>SimpleX name not verified</source>
</trans-unit>
<trans-unit id="%d owners" xml:space="preserve">
<source>%d owners</source>
</trans-unit>
<trans-unit id="You are a moderator" xml:space="preserve">
<source>You are a moderator</source>
</trans-unit>
<trans-unit id="None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link." xml:space="preserve">
<source>None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link.</source>
</trans-unit>
<trans-unit id="until you can register a SimpleX domain" xml:space="preserve">
<source>until you can register a SimpleX domain</source>
</trans-unit>
<trans-unit id="Your contact removed this link, or it was a one-time link that was already used.&#10;To connect, ask your contact to create a new link." xml:space="preserve">
<source>Your contact removed this link, or it was a one-time link that was already used.
To connect, ask your contact to create a new link.</source>
</trans-unit>
<trans-unit id="You can now invest in SimpleX Chat! 🚀" xml:space="preserve">
<source>You can now invest in SimpleX Chat! 🚀</source>
</trans-unit>
<trans-unit id="Let people join via name registered with this channel link." xml:space="preserve">
<source>Let people join via name registered with this channel link.</source>
</trans-unit>
<trans-unit id="Resolver error: %@" xml:space="preserve">
<source>Resolver error: %@</source>
</trans-unit>
<trans-unit id="How to register a test name" xml:space="preserve">
<source>How to register a test name</source>
</trans-unit>
<trans-unit id="%02d hrs %02d min %02d sec" xml:space="preserve">
<source>%1$02d hrs %2$02d min %3$02d sec</source>
</trans-unit>
<trans-unit id="Crowdfunding on Wefunder" xml:space="preserve">
<source>Crowdfunding on Wefunder</source>
</trans-unit>
<trans-unit id="Easier to read." xml:space="preserve">
<source>Easier to read.</source>
</trans-unit>
<trans-unit id="Signed &amp; verified" xml:space="preserve">
<source>Signed &amp; verified</source>
</trans-unit>
<trans-unit id="%d day" xml:space="preserve">
<source>%d day</source>
</trans-unit>
<trans-unit id="The channel required this message to be signed, but the signature is missing." xml:space="preserve">
<source>The channel required this message to be signed, but the signature is missing.</source>
</trans-unit>
<trans-unit id="Add contributors." xml:space="preserve">
<source>Add contributors.</source>
</trans-unit>
<trans-unit id="Verify name" xml:space="preserve">
<source>Verify name</source>
</trans-unit>
<trans-unit id="Crowdfunding on Wefunder." xml:space="preserve">
<source>Crowdfunding on Wefunder.</source>
</trans-unit>
<trans-unit id="Your SimpleX name" xml:space="preserve">
<source>Your SimpleX name</source>
</trans-unit>
<trans-unit id="Contact requests in groups" xml:space="preserve">
<source>Contact requests in groups</source>
</trans-unit>
<trans-unit id="You are a member" xml:space="preserve">
<source>You are a member</source>
</trans-unit>
<trans-unit id="To verify keys with this subscriber, compare (or scan) the code on your devices." xml:space="preserve">
<source>To verify keys with this subscriber, compare (or scan) the code on your devices.</source>
</trans-unit>
<trans-unit id="Edit description" xml:space="preserve">
<source>Edit description</source>
</trans-unit>
<trans-unit id="Message signing is required." xml:space="preserve">
<source>Message signing is required.</source>
</trans-unit>
<trans-unit id="Group invitations" xml:space="preserve">
<source>Group invitations</source>
</trans-unit>
<trans-unit id="%d owners &amp; contributors" xml:space="preserve">
<source>%d owners &amp; contributors</source>
</trans-unit>
<trans-unit id="Signature missing" xml:space="preserve">
<source>Signature missing</source>
</trans-unit>
<trans-unit id="Better channels 📢" xml:space="preserve">
<source>Better channels 📢</source>
</trans-unit>
<trans-unit id="File servers: %@" xml:space="preserve">
<source>File servers: %@</source>
</trans-unit>
<trans-unit id="Signed" xml:space="preserve">
<source>Signed</source>
</trans-unit>
<trans-unit id="Public names for your channel or business." xml:space="preserve">
<source>Public names for your channel or business.</source>
</trans-unit>
<trans-unit id="Name not found" xml:space="preserve">
<source>Name not found</source>
</trans-unit>
<trans-unit id="To resolve names" xml:space="preserve">
<source>To resolve names</source>
</trans-unit>
<trans-unit id="Remove name" xml:space="preserve">
<source>Remove name</source>
</trans-unit>
<trans-unit id="Add description" xml:space="preserve">
<source>Add description</source>
</trans-unit>
<trans-unit id="You are an admin" xml:space="preserve">
<source>You are an admin</source>
</trans-unit>
<trans-unit id="Create web preview." xml:space="preserve">
<source>Create web preview.</source>
</trans-unit>
<trans-unit id="Destination server version of %1$@ is incompatible with forwarding server %2$@." xml:space="preserve">
<source>Destination server version of %1$@ is incompatible with forwarding server %2$@.</source>
</trans-unit>
<trans-unit id="The SimpleX name @%@ is registered without SimpleX address. Add your SimpleX address to the name via the registration page." xml:space="preserve">
<source>The SimpleX name @%@ is registered without SimpleX address. Add your SimpleX address to the name via the registration page.</source>
</trans-unit>
<trans-unit id="Don't save" xml:space="preserve">
<source>Don't save</source>
</trans-unit>
<trans-unit id="You are a contributor" xml:space="preserve">
<source>You are a contributor</source>
</trans-unit>
<trans-unit id="Server %@ does not support name resolution. Configure servers, or use a connection link." xml:space="preserve">
<source>Server %@ does not support name resolution. Configure servers, or use a connection link.</source>
</trans-unit>
<trans-unit id="saved from" xml:space="preserve">
<source>saved from</source>
</trans-unit>
<trans-unit id="Connection blocked: %@" xml:space="preserve">
<source>Connection blocked: %@</source>
</trans-unit>
<trans-unit id="Do not require signing messages." xml:space="preserve">
<source>Do not require signing messages.</source>
</trans-unit>
<trans-unit id="File servers" xml:space="preserve">
<source>File servers</source>
</trans-unit>
<trans-unit id="Connect to %@" xml:space="preserve">
<source>Connect to %@</source>
</trans-unit>
<trans-unit id="Connection link removed" xml:space="preserve">
<source>Connection link removed</source>
</trans-unit>
<trans-unit id="No valid link" xml:space="preserve">
<source>No valid link</source>
</trans-unit>
<trans-unit id="Signing proves you authored this message and can't be denied later." xml:space="preserve">
<source>Signing proves you authored this message and can't be denied later.</source>
</trans-unit>
<trans-unit id="You are an owner" xml:space="preserve">
<source>You are an owner</source>
</trans-unit>
<trans-unit id="Change role?" xml:space="preserve">
<source>Change role?</source>
</trans-unit>
<trans-unit id="Destination server address of %1$@ is incompatible with forwarding server %2$@ settings." xml:space="preserve">
<source>Destination server address of %1$@ is incompatible with forwarding server %2$@ settings.</source>
</trans-unit>
<trans-unit id="You are a subscriber" xml:space="preserve">
<source>You are a subscriber</source>
</trans-unit>
<trans-unit id="Let people connect to you via name registered with your SimpleX address." xml:space="preserve">
<source>Let people connect to you via name registered with your SimpleX address.</source>
</trans-unit>
<trans-unit id="You can now invest in SimpleX Chat" xml:space="preserve">
<source>You can now invest in SimpleX Chat</source>
</trans-unit>
<trans-unit id="Verify SimpleX names" xml:space="preserve">
<source>Verify SimpleX names</source>
</trans-unit>
<trans-unit id="Error saving name" xml:space="preserve">
<source>Error saving name</source>
</trans-unit>
<trans-unit id="This SimpleX name is not registered. Please check the name." xml:space="preserve">
<source>This SimpleX name is not registered. Please check the name.</source>
</trans-unit>
<trans-unit id="%d owner" xml:space="preserve">
<source>%d owner</source>
</trans-unit>
<trans-unit id="Message signing is not required." xml:space="preserve">
<source>Message signing is not required.</source>
</trans-unit>
<trans-unit id="SimpleX name" xml:space="preserve">
<source>SimpleX name</source>
</trans-unit>
<trans-unit id="SimpleX public names (BETA)" xml:space="preserve">
<source>SimpleX public names (BETA)</source>
</trans-unit>
<trans-unit id="Unconfirmed name" xml:space="preserve">
<source>Unconfirmed name</source>
</trans-unit>
<trans-unit id="Save SimpleX name?" xml:space="preserve">
<source>Save SimpleX name?</source>
</trans-unit>
<trans-unit id="Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)" xml:space="preserve">
<source>Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)</source>
</trans-unit>
<trans-unit id="Show encryption" xml:space="preserve">
<source>Show encryption</source>
</trans-unit>
<trans-unit id="Enter description (optional)" xml:space="preserve">
<source>Enter description (optional)</source>
</trans-unit>
<trans-unit id="The SimpleX name #%@ is registered without channel link. Add channel link to the name via the registration page." xml:space="preserve">
<source>The SimpleX name #%@ is registered without channel link. Add channel link to the name via the registration page.</source>
</trans-unit>
<trans-unit id="You are an observer" xml:space="preserve">
<source>You are an observer</source>
</trans-unit>
<trans-unit id="Get SimpleX name (BETA)" xml:space="preserve">
<source>Get SimpleX name (BETA)</source>
</trans-unit>
<trans-unit id="Join channel %@" xml:space="preserve">
<source>Join channel %@</source>
</trans-unit>
<trans-unit id="Sign message" xml:space="preserve">
<source>Sign message</source>
</trans-unit>
<trans-unit id="SimpleX name error" xml:space="preserve">
<source>SimpleX name error</source>
</trans-unit>
<trans-unit id="subscriber" xml:space="preserve">
<source>subscriber</source>
</trans-unit>
<trans-unit id="No servers to resolve names." xml:space="preserve">
<source>No servers to resolve names.</source>
</trans-unit>
<trans-unit id="Sign messages" xml:space="preserve">
<source>Sign messages</source>
</trans-unit>
<trans-unit id="Manage your relays." xml:space="preserve">
<source>Manage your relays.</source>
</trans-unit>
<trans-unit id="Channel SimpleX name" xml:space="preserve">
<source>Channel SimpleX name</source>
</trans-unit>
<trans-unit id="Role will be changed to &quot;%@&quot;. All subscribers will be notified." xml:space="preserve">
<source>Role will be changed to "%@". All subscribers will be notified.</source>
</trans-unit>
</body>
</file>
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="zh-Hant" datatype="plaintext">
@@ -11849,4 +12117,14 @@ If you cancel, the channel will be deleted - you can create it again.</source>
</trans-unit>
</body>
</file>
<file original="en.lproj/SimpleXChat-InfoPlist.strings" source-language="en" target-language="zh-Hant" datatype="plaintext">
<body>
<trans-unit id="CFBundleName" translate="no" xml:space="preserve">
<source>SimpleXChat</source>
</trans-unit>
<trans-unit id="NSHumanReadableCopyright" translate="no" xml:space="preserve">
<source>Copyright © 2022 SimpleX Chat. All rights reserved.</source>
</trans-unit>
</body>
</file>
</xliff>
+3
View File
@@ -89,6 +89,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult {
case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?)
case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest)
case newChatItems(user: UserRef, chatItems: [AChatItem])
case duplicateGroupMessage(user: UserRef, member: GroupMember)
case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer)
case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer)
@@ -101,6 +102,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult {
case .contactConnected: "contactConnected"
case .receivedContactRequest: "receivedContactRequest"
case .newChatItems: "newChatItems"
case .duplicateGroupMessage: "duplicateGroupMessage"
case .rcvFileSndCancelled: "rcvFileSndCancelled"
case .sndFileComplete: "sndFileComplete"
case .sndFileRcvCancelled: "sndFileRcvCancelled"
@@ -117,6 +119,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult {
case let .newChatItems(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
return withUser(u, itemsString)
case let .duplicateGroupMessage(u, member): return withUser(u, String(describing: member))
case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem))
case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
+16 -4
View File
@@ -73,6 +73,16 @@ public enum NSENotificationData {
default: nil
}
}
func replaces(_ bestAttempt: NSENotificationData) -> Bool {
switch (self, bestAttempt) {
case (.noNtf, .connectionEvent): true
case (.noNtf, _): false
case (.callInvitation, _): true
case (_, .callInvitation): false
default: true
}
}
}
// Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid
@@ -573,9 +583,9 @@ class NotificationService: UNNotificationServiceExtension {
// A better logic could be to check whether we have a call in the best attempt while processing .msgInfo marker above.
// If the best attempt is a call, and its marker is received, and the call is recent (e.g., the last 30 seconds), it would deliver at once,
// instead of requesting further messages.
if ntfEntity.msgBestAttemptNtf.callInvitation == nil || ntf.callInvitation != nil {
if let bestAttempt = notificationEntities[id]?.msgBestAttemptNtf, ntf.replaces(bestAttempt) {
notificationEntities[id]?.msgBestAttemptNtf = ntf
} // otherwise keep call as best attempt
}
return false
} else {
// We should not get to this branch, as notifications are not delivered for muted users.
@@ -1022,8 +1032,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)?
cItem = autoReceiveFile(file) ?? cItem
}
let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf
let chatIdOrMemberId = if case let .groupRcv(groupMember) = chatItem.chatItem.chatDir {
groupMember.id
let chatIdOrMemberId = if case let .group(groupInfo, _) = cInfo, let memberId = cItem.meta.forwardedByMember ?? cItem.chatDir.groupMemberId {
groupMemberEntityId(groupInfo.groupId, memberId)
} else {
chatItem.chatInfo.id
}
@@ -1031,6 +1041,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)?
} else {
return nil
}
case let .duplicateGroupMessage(_, member):
return (member.id, .noNtf)
case let .rcvFileSndCancelled(_, aChatItem, _):
cleanupFile(aChatItem)
return nil
+3 -3
View File
@@ -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)
@@ -16,9 +16,6 @@
/* No comment provided by engineer. */
"Comment" = "Kommentieren";
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "Die maximal erlaubte Dateigröße beträgt aktuell %@.";
/* No comment provided by engineer. */
"Database downgrade required" = "Datenbank-Herunterstufung ist erforderlich";
@@ -16,9 +16,6 @@
/* No comment provided by engineer. */
"Comment" = "Comentario";
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "El tamaño máximo de archivo admitido es %@.";
/* No comment provided by engineer. */
"Database downgrade required" = "Se requiere volver a versión anterior de la base de datos";
@@ -16,9 +16,6 @@
/* No comment provided by engineer. */
"Comment" = "Commenter";
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "Actuellement, la taille maximale des fichiers supportés est de %@.";
/* No comment provided by engineer. */
"Database downgrade required" = "Mise à jour de la base de données nécessaire";
@@ -16,9 +16,6 @@
/* No comment provided by engineer. */
"Comment" = "Megjegyzés";
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "Jelenleg támogatott legnagyobb fájlméret: %@.";
/* No comment provided by engineer. */
"Database downgrade required" = "Adatbázis visszafejlesztése szükséges";
@@ -16,9 +16,6 @@
/* No comment provided by engineer. */
"Comment" = "Commento";
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "Attualmente la dimensione massima supportata è di %@.";
/* No comment provided by engineer. */
"Database downgrade required" = "Downgrade del database necessario";
@@ -16,9 +16,6 @@
/* No comment provided by engineer. */
"Comment" = "Opmerking";
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "De momenteel maximaal ondersteunde bestandsgrootte is %@.";
/* No comment provided by engineer. */
"Database downgrade required" = "Database downgrade vereist";
@@ -16,9 +16,6 @@
/* No comment provided by engineer. */
"Comment" = "Komentarz";
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "Obecnie maksymalny obsługiwany rozmiar pliku to %@.";
/* No comment provided by engineer. */
"Database downgrade required" = "Wymagane obniżenie wersji bazy danych";
@@ -16,9 +16,6 @@
/* No comment provided by engineer. */
"Comment" = "Комментарий";
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "В настоящее время максимальный поддерживаемый размер файла составляет %@.";
/* No comment provided by engineer. */
"Database downgrade required" = "Требуется откат базы данных";
@@ -16,9 +16,6 @@
/* No comment provided by engineer. */
"Comment" = "Yorum";
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "Şu anki maksimum desteklenen dosya boyutu %@ kadardır.";
/* No comment provided by engineer. */
"Database downgrade required" = "Veritabanı sürüm düşürme gerekli";
@@ -16,9 +16,6 @@
/* No comment provided by engineer. */
"Comment" = "Коментар";
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "Наразі максимальний підтримуваний розмір файлу - %@.";
/* No comment provided by engineer. */
"Database downgrade required" = "Потрібне оновлення бази даних";
@@ -16,9 +16,6 @@
/* No comment provided by engineer. */
"Comment" = "评论";
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "当前支持的最大文件大小为 %@。";
/* No comment provided by engineer. */
"Database downgrade required" = "需要数据库降级";
+10
View File
@@ -42,6 +42,16 @@
<string>simplex</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>chat.simplex.app.deeplink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>simplexchat</string>
</array>
</dict>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
+44 -26
View File
@@ -166,6 +166,8 @@
647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E72F4C8D2500EB431E /* AddChannelView.swift */; };
647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */; };
647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; };
647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */; };
647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2B30666C640032110E /* BadgesHowLongView.swift */; };
647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; };
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
@@ -181,24 +183,24 @@
64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF1302F423300072BDE /* BadgesPayView.swift */; };
64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */; };
64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */; };
64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */; };
64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF4302F423300072BDE /* BadgeStore.swift */; };
64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */; };
64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */; };
64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */; };
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; };
64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */; };
64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */; };
64E5E3632DF71A4E00A4D530 /* ContextContactRequestActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E5E3622DF71A4E00A4D530 /* ContextContactRequestActionsView.swift */; };
64E5E3672DFC16A900A4D530 /* ContextProfilePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E5E3662DFC16A900A4D530 /* ContextProfilePickerView.swift */; };
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; };
@@ -207,6 +209,7 @@
64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */; };
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; };
64FC8F9D2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64FC8F9C2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift */; };
6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */; };
8C01E9C12C8EFC33008A4B0A /* objc.m in Sources */ = {isa = PBXBuildFile; fileRef = 8C01E9C02C8EFC33008A4B0A /* objc.m */; };
8C01E9C22C8EFF8F008A4B0A /* objc.h in Headers */ = {isa = PBXBuildFile; fileRef = 8C01E9BF2C8EFBB6008A4B0A /* objc.h */; };
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; };
@@ -268,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 */; };
@@ -558,6 +563,8 @@
647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = "<group>"; };
647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = "<group>"; };
647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = "<group>"; };
647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesChooseBadgeView.swift; sourceTree = "<group>"; };
647C9E2B30666C640032110E /* BadgesHowLongView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowLongView.swift; sourceTree = "<group>"; };
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = "<group>"; };
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
@@ -575,23 +582,23 @@
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = "<group>"; };
64C03BF1302F423300072BDE /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = "<group>"; };
64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = "<group>"; };
64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = "<group>"; };
64C03BF4302F423300072BDE /* BadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeStore.swift; sourceTree = "<group>"; };
64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = "<group>"; };
64C03BF6302F423300072BDE /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = "<group>"; };
64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = "<group>"; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = "<group>"; };
64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesCheckOrderView.swift; sourceTree = "<group>"; };
64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPurchaseStateView.swift; sourceTree = "<group>"; };
64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
64E5E3622DF71A4E00A4D530 /* ContextContactRequestActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextContactRequestActionsView.swift; sourceTree = "<group>"; };
64E5E3662DFC16A900A4D530 /* ContextProfilePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextProfilePickerView.swift; sourceTree = "<group>"; };
@@ -601,6 +608,7 @@
64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersSummaryView.swift; sourceTree = "<group>"; };
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; };
64FC8F9C2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMemberContactActionsView.swift; sourceTree = "<group>"; };
6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownFileView.swift; sourceTree = "<group>"; };
8C01E9BF2C8EFBB6008A4B0A /* objc.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = objc.h; sourceTree = "<group>"; };
8C01E9C02C8EFC33008A4B0A /* objc.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = objc.m; sourceTree = "<group>"; };
8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
@@ -656,6 +664,7 @@
E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = "<group>"; };
E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = "<group>"; };
E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = "<group>"; };
E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SharedAssets.xcassets; sourceTree = "<group>"; };
E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectBannerCard.swift; sourceTree = "<group>"; };
@@ -760,8 +769,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -848,8 +857,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -947,6 +956,7 @@
5C2E260D27A30E2400F70299 /* Views */,
5CA059C5279559F40002BEB4 /* Assets.xcassets */,
E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */,
E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */,
D7AA2C3429A936B400737B40 /* MediaEncryption.playground */,
5C13730C2815740A00F43030 /* DebugJSON.playground */,
);
@@ -1123,6 +1133,7 @@
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */,
648010AA281ADD15009009B9 /* CIFileView.swift */,
6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */,
644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */,
3CDBCF4727FF621E00354CDD /* CILinkView.swift */,
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */,
@@ -1217,14 +1228,16 @@
64C03BF8302F423300072BDE /* Badges */ = {
isa = PBXGroup;
children = (
647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */,
647C9E2B30666C640032110E /* BadgesHowLongView.swift */,
64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */,
64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */,
644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */,
64EB8A9A3054347A0089FFDF /* BadgesView.swift */,
64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */,
64C03BF1302F423300072BDE /* BadgesPayView.swift */,
64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */,
64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */,
64C03BF4302F423300072BDE /* BadgeStore.swift */,
64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */,
64C03BF6302F423300072BDE /* BadgeUserPreview.swift */,
64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */,
64EB8A98305405C60089FFDF /* BadgesYourBadgeView.swift */,
@@ -1497,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;
};
@@ -1529,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;
@@ -1600,6 +1615,7 @@
5CB634A829E437960066AD6B /* PasscodeEntry.swift in Sources */,
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */,
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */,
6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */,
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */,
5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */,
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */,
@@ -1649,13 +1665,11 @@
640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */,
5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */,
5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */,
64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */,
64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */,
64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */,
64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */,
64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */,
64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */,
64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */,
64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */,
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */,
5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */,
@@ -1672,6 +1686,10 @@
CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */,
647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */,
647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */,
64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */,
64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */,
5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */,
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */,
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */,
@@ -2139,7 +2157,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 362;
CURRENT_PROJECT_VERSION = 363;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2189,7 +2207,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 362;
CURRENT_PROJECT_VERSION = 363;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2231,7 +2249,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 362;
CURRENT_PROJECT_VERSION = 363;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2251,7 +2269,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 362;
CURRENT_PROJECT_VERSION = 363;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2276,7 +2294,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 362;
CURRENT_PROJECT_VERSION = 363;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2313,7 +2331,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 362;
CURRENT_PROJECT_VERSION = 363;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2350,7 +2368,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 362;
CURRENT_PROJECT_VERSION = 363;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2401,7 +2419,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 362;
CURRENT_PROJECT_VERSION = 363;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2455,7 +2473,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 362;
CURRENT_PROJECT_VERSION = 363;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2489,7 +2507,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 362;
CURRENT_PROJECT_VERSION = 363;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
+8 -4
View File
@@ -149,12 +149,15 @@ public func recvSimpleXMsg<R: ChatAPIResult>(_ 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? {
Loaded 100 of 686 files, more files were not shown because too many files have changed in this diff. Show more