mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 10:43:05 +00:00
Merge branch 'master' into f/channel-multi-owner
This commit is contained in:
commit
52adcfe72c
686 files changed
+109616
-4558
No files matched your search
@@ -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') }}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-52
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
|
||||
private func imagesWithinBudget() -> Modifier {
|
||||
var pixels = MAX_MARKDOWN_IMAGE_PIXELS
|
||||
return Modifier(target: .images) { image in
|
||||
let src = image.html.dropFirst("<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))
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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. 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 & verified" xml:space="preserve">
|
||||
<source>Signed & 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 & contributors" xml:space="preserve">
|
||||
<source>%d owners & 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 "%@". 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>
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" = "需要数据库降级";
|
||||
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in new issue
Block a user