mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 08:33:09 +00:00
Merge branch 'nd/set-group-members-main-thread' into nd/support-list-events
# Conflicts: # apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt
This commit is contained in:
commit
b37e62893c
1159 files changed
+42813
-5984
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,30 @@
|
||||
name: android-emu
|
||||
|
||||
# A placeholder, so that the Android app test workflow can be started by hand on a branch that has its full
|
||||
# version: GitHub dispatches only workflows whose file is on the default branch. On master it does nothing.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
claude:
|
||||
description: Also run the Claude job
|
||||
type: boolean
|
||||
default: false
|
||||
backend:
|
||||
description: Also start the test backend
|
||||
type: boolean
|
||||
default: false
|
||||
release:
|
||||
description: Also test the latest release's onboarding
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
placeholder:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Nothing to run here
|
||||
run: echo "This branch has only the placeholder of android-emu; start it on a branch with the full workflow."
|
||||
+1036
-162
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,481 @@
|
||||
name: desktop-linux
|
||||
|
||||
# A push runs the jobs only when its head commit's message asks for them: see scripts/ci/desktop-linux/README.md.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
# only f/ branches, so other branches' pull requests don't list skipped runs; elsewhere, run it by hand
|
||||
- "f/*"
|
||||
# a squash merge's message lists the merged commits' subjects, with their tags
|
||||
- "!master"
|
||||
- "!stable"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
claude:
|
||||
description: Also run the Claude job, which spends the Claude subscription's usage limits
|
||||
type: boolean
|
||||
default: false
|
||||
backend:
|
||||
description: Also start the test backend
|
||||
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:
|
||||
# a push that asks for no run must not cancel one that does
|
||||
group: desktop-linux-${{ github.ref }}-${{ (github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:desktop]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]')) && 'requested' || github.run_id }}
|
||||
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
|
||||
# the concurrency group repeats this test, as it can't read job outputs
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.head_commit.message, '[app-ci]') ||
|
||||
contains(github.event.head_commit.message, '[app-ci:desktop]') ||
|
||||
contains(github.event.head_commit.message, '[claude-test]') ||
|
||||
contains(github.event.head_commit.message, '[claude-test:desktop]')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
run-claude: ${{ steps.jobs.outputs.claude }}
|
||||
run-backend: ${{ steps.jobs.outputs.backend }}
|
||||
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 || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]') }}
|
||||
BACKEND: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }}
|
||||
RELEASE: ${{ inputs.release || contains(github.event.head_commit.message, '[ci-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,412 @@
|
||||
name: ios-sim
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
# only f/ branches, so other branches' pull requests don't list skipped runs; elsewhere, run it by hand
|
||||
- 'f/*'
|
||||
# a squash merge's message lists the merged commits' subjects, with their tags
|
||||
- '!master'
|
||||
- '!stable'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
claude:
|
||||
description: Also run the Claude session
|
||||
type: boolean
|
||||
default: false
|
||||
backend:
|
||||
description: Start the test backend
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
# a push that asks for no run must not cancel one that does
|
||||
group: ios-sim-${{ github.ref }}-${{ (github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:ios]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]')) && 'requested' || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GHC_VER: 9.6.3
|
||||
CABAL_VER: 3.10.2.0
|
||||
CORE_PKG: pkg-ios-aarch64-swift-json.zip
|
||||
BACKEND_PKG: ios-sim-backend.zip
|
||||
HOMEBREW_NO_AUTO_UPDATE: 1
|
||||
HOMEBREW_NO_INSTALL_CLEANUP: 1
|
||||
# newer images add Command Line Tools whose macOS SDK the default Xcode's linker cannot read
|
||||
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
name: choose jobs, find earlier builds
|
||||
# the concurrency group repeats this test, as it can't read job outputs
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
contains(github.event.head_commit.message, '[app-ci]') ||
|
||||
contains(github.event.head_commit.message, '[app-ci:ios]') ||
|
||||
contains(github.event.head_commit.message, '[claude-test]') ||
|
||||
contains(github.event.head_commit.message, '[claude-test:ios]')
|
||||
runs-on: ubuntu-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 || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]') }}
|
||||
BACKEND: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }}
|
||||
run: |
|
||||
echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT"
|
||||
echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout code
|
||||
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,74 @@
|
||||
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 }}
|
||||
test: ${{ steps.plan.outputs.test }}
|
||||
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)
|
||||
if: needs.addon.outputs.test
|
||||
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/
|
||||
@@ -179,6 +179,7 @@ SimpleX Chat founder
|
||||
- [Privacy and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations)
|
||||
- [For developers](#for-developers)
|
||||
- [Develop a chat bot](#develop-a-chat-bot)
|
||||
- [Agents](#agents)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Disclaimers, Security contact, License](#disclaimers)
|
||||
|
||||
@@ -331,6 +332,18 @@ See [our new bot API reference](./bots/README.md). Most of it is automatically g
|
||||
|
||||
Also see [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
|
||||
|
||||
## Agents
|
||||
|
||||
- For AI assistants: [simplex.chat/llms.txt](https://simplex.chat/llms.txt), an index of the documentation in Markdown, and [SimpleX for AI agents and developers](https://simplex.chat/llms/agents.md).
|
||||
- Every page of simplex.chat is also available as Markdown: replace `.html` with `.md` ([simplex.chat/docs/simplex.md](https://simplex.chat/docs/simplex.md)), or add `.md` to the path ([simplex.chat/why.md](https://simplex.chat/why.md)).
|
||||
- Libraries: [Node.js](./packages/simplex-chat-nodejs/) ([npm](https://www.npmjs.com/package/simplex-chat)) and [Python](./packages/simplex-chat-python/) ([PyPI](https://pypi.org/project/simplex-chat/)).
|
||||
- [Bot API guide](./bots/README.md) and [API reference](./bots/api/README.md): commands, events and types.
|
||||
- Example bots: [support bot](./apps/simplex-support-bot/) and [calculator](./apps/simplex-calculator-bot/) in TypeScript, [support bot light](./apps/simplex-support-bot-light/) in Python, [directory service](./apps/simplex-directory-service/) and [broadcast bot](./apps/simplex-broadcast-bot/) in Haskell.
|
||||
- [Terminal client](./docs/CLI.md): installation, building from source and usage.
|
||||
- Servers: [SMP](./docs/SERVER.md), [XFTP](./docs/XFTP-SERVER.md) and [chat relay](./docs/CHAT-RELAY.md).
|
||||
- Protocols: [SimpleX network](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md), [chat](./docs/protocol/simplex-chat.md), [channels](./docs/protocol/channels-overview.md) and [public names](./docs/protocol/names-overview.md).
|
||||
- For coding agents working in this repository: [project structure](./docs/contributing/PROJECT.md) and [coding and building](./docs/contributing/CODE.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -1191,6 +1226,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
|
||||
// group events
|
||||
case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set<Int64>, byUser: Bool, member_: GroupMember?)
|
||||
case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole)
|
||||
case receivedGroupInvitationViaGroup(user: UserRef, groupInfo: GroupInfo, viaGroupInfo: GroupInfo, viaMember: GroupMember, memberRole: GroupMemberRole)
|
||||
case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?)
|
||||
case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember)
|
||||
case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact)
|
||||
@@ -1248,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 {
|
||||
@@ -1272,6 +1310,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
|
||||
case .chatItemsDeleted: "chatItemsDeleted"
|
||||
case .groupChatItemsDeleted: "groupChatItemsDeleted"
|
||||
case .receivedGroupInvitation: "receivedGroupInvitation"
|
||||
case .receivedGroupInvitationViaGroup: "receivedGroupInvitationViaGroup"
|
||||
case .userAcceptedGroupSent: "userAcceptedGroupSent"
|
||||
case .groupLinkConnecting: "groupLinkConnecting"
|
||||
case .businessLinkConnecting: "businessLinkConnecting"
|
||||
@@ -1322,6 +1361,8 @@ enum ChatEvent: Decodable, ChatAPIResult {
|
||||
case .contactPQEnabled: "contactPQEnabled"
|
||||
case .badgeChanged: "badgeChanged"
|
||||
case .badgeAlert: "badgeAlert"
|
||||
case .storePurchaseCredited: "storePurchaseCredited"
|
||||
case .storePurchaseRefused: "storePurchaseRefused"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1356,6 +1397,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
|
||||
case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_):
|
||||
return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))")
|
||||
case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)")
|
||||
case let .receivedGroupInvitationViaGroup(u, groupInfo, viaGroupInfo, viaMember, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nviaGroupInfo: \(viaGroupInfo)\nviaMember: \(viaMember)\nmemberRole: \(memberRole)")
|
||||
case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))")
|
||||
case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))")
|
||||
case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))")
|
||||
@@ -1406,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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1931,10 +1975,13 @@ enum UserServersError: Decodable {
|
||||
case storageMissing(protocol: ServerProtocol, user: UserRef?)
|
||||
case proxyMissing(protocol: ServerProtocol, user: UserRef?)
|
||||
case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String)
|
||||
case tooManyHosts(protocol: ServerProtocol, tooManyHostsServer: String)
|
||||
case duplicateChatRelayAddress(duplicateChatRelay: String, duplicateAddress: String)
|
||||
|
||||
var globalError: String? {
|
||||
switch self {
|
||||
case .tooManyHosts:
|
||||
return globalSMPError
|
||||
case let .noServers(`protocol`, _):
|
||||
switch `protocol` {
|
||||
case .smp: return globalSMPError
|
||||
@@ -1977,6 +2024,8 @@ enum UserServersError: Decodable {
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .tooManyHosts(.smp, server):
|
||||
return String.localizedStringWithFormat(NSLocalizedString("Server %@ has more than two host names.", comment: "servers error"), serverHostname(server))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -935,6 +952,8 @@ final class ChatModel: ObservableObject {
|
||||
func updateCurrentUser(_ newProfile: Profile, _ preferences: FullPreferences? = nil) {
|
||||
if let current = currentUser {
|
||||
currentUser?.profile = toLocalProfile(current.profile.profileId, newProfile, "")
|
||||
currentUser?.profile.localBadge = current.profile.localBadge
|
||||
currentUser?.profile.contactDomainVerified = current.profile.contactDomainVerified
|
||||
if let preferences = preferences {
|
||||
currentUser?.fullPreferences = preferences
|
||||
}
|
||||
|
||||
@@ -1362,8 +1362,12 @@ func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? {
|
||||
switch r {
|
||||
case .result(.userProfileNoChange): return (profile, [])
|
||||
case let .result(.userProfileUpdated(_, _, toProfile, updateSummary)): return (toProfile, updateSummary.changedContacts)
|
||||
case .error(.errorStore(.duplicateName)): return nil;
|
||||
default: throw r.unexpected
|
||||
case .error(.errorStore(.duplicateName)), .error(.error(.userExists)): return nil;
|
||||
default:
|
||||
await MainActor.run {
|
||||
showErrorAlert(r.unexpected, NSLocalizedString("Error saving profile", comment: "alert title"))
|
||||
}
|
||||
throw r.unexpected
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1849,8 +1853,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 +2204,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 +2249,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 +2269,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 +2398,11 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? {
|
||||
@@ -2389,6 +2427,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 {
|
||||
@@ -2404,6 +2443,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
|
||||
@@ -2431,22 +2471,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
|
||||
@@ -2703,7 +2753,8 @@ func processReceivedMsg(_ res: ChatEvent) async {
|
||||
}
|
||||
case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_):
|
||||
await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_)
|
||||
case let .receivedGroupInvitation(user, groupInfo, _, _):
|
||||
case let .receivedGroupInvitation(user, groupInfo, _, _),
|
||||
let .receivedGroupInvitationViaGroup(user, groupInfo, _, _, _):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
|
||||
@@ -2870,6 +2921,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) }
|
||||
@@ -2916,23 +2970,19 @@ func processReceivedMsg(_ res: ChatEvent) async {
|
||||
m.callInvitations[invitation.contact.id] = invitation
|
||||
}
|
||||
activateCall(invitation)
|
||||
case let .callOffer(_, contact, callType, offer, sharedKey, _):
|
||||
case let .callOffer(_, contact, callType, offer, sharedKey, askConfirmation):
|
||||
await withCall(contact) { call in
|
||||
await MainActor.run {
|
||||
call.callState = .offerReceived
|
||||
call.sharedKey = sharedKey
|
||||
}
|
||||
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||
let iceServers = getIceServers()
|
||||
logger.debug(".callOffer useRelay \(useRelay)")
|
||||
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
|
||||
await m.callCommand.processCommand(.offer(
|
||||
offer: offer.rtcSession,
|
||||
iceCandidates: offer.rtcIceCandidates,
|
||||
media: callType.media, aesKey: sharedKey,
|
||||
iceServers: iceServers,
|
||||
relay: useRelay
|
||||
))
|
||||
if askConfirmation {
|
||||
showUnencryptedCallAlert(call) {
|
||||
Task { await processCallOffer(callType, offer, sharedKey) }
|
||||
}
|
||||
} else {
|
||||
await processCallOffer(callType, offer, sharedKey)
|
||||
}
|
||||
}
|
||||
case let .callAnswer(_, contact, answer):
|
||||
await withCall(contact) { call in
|
||||
@@ -2943,7 +2993,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) }) {
|
||||
@@ -3058,6 +3108,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)")
|
||||
}
|
||||
@@ -3069,6 +3123,43 @@ func processReceivedMsg(_ res: ChatEvent) async {
|
||||
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
|
||||
}
|
||||
}
|
||||
|
||||
func processCallOffer(_ callType: CallType, _ offer: WebRTCSession, _ sharedKey: String?) async {
|
||||
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||
let iceServers = getIceServers()
|
||||
logger.debug(".callOffer useRelay \(useRelay)")
|
||||
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
|
||||
await m.callCommand.processCommand(.offer(
|
||||
offer: offer.rtcSession,
|
||||
iceCandidates: offer.rtcIceCandidates,
|
||||
media: callType.media, aesKey: sharedKey,
|
||||
iceServers: iceServers,
|
||||
relay: useRelay
|
||||
))
|
||||
}
|
||||
|
||||
func showUnencryptedCallAlert(_ call: Call, onContinue: @escaping () -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
showAlert(
|
||||
NSLocalizedString("Call is not encrypted", comment: "alert title"),
|
||||
message: String.localizedStringWithFormat(NSLocalizedString("%@ accepted the call without end-to-end encryption.", comment: "alert message"), call.contact.displayName),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("End call", comment: "alert action"), style: .destructive) { _ in
|
||||
// the call may have ended, or a new one started with the same contact, while the alert was shown
|
||||
guard m.activeCall === call else { return }
|
||||
if let uuid = call.callUUID {
|
||||
CallController.shared.endCall(callUUID: uuid)
|
||||
} else {
|
||||
CallController.shared.endCall(call: call) {}
|
||||
}
|
||||
},
|
||||
UIAlertAction(title: NSLocalizedString("Continue", comment: "alert action"), style: .default) { _ in
|
||||
if m.activeCall === call { onContinue() }
|
||||
}
|
||||
]}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switchToLocalSession() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -39,6 +39,13 @@ struct CIGroupInvitationView: View {
|
||||
|
||||
Divider().frame(width: frameWidth)
|
||||
|
||||
if chat.chatInfo.groupInfo != nil {
|
||||
Text(chatItem.chatDir.sent ? String.localizedStringWithFormat(NSLocalizedString("For %@ only", comment: ""), groupInvitation.localDisplayName) : NSLocalizedString("For you only", comment: ""))
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.overlay(DetermineWidth())
|
||||
}
|
||||
|
||||
if action {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
groupInvitationText()
|
||||
@@ -108,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)
|
||||
|
||||
@@ -767,6 +767,7 @@ struct ComposeView: View {
|
||||
let connectedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connStatus == .ready && $0.wrapped.activeConn?.connFailedErr == nil }.count
|
||||
let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count
|
||||
let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count
|
||||
guard total > 0 || chatModel.membersLoaded else { return nil }
|
||||
let noActiveRelays = connectedCount == 0 && (removedCount + failedCount) == total
|
||||
return (hostnames, relayMembers, connectedCount, removedCount, failedCount, total, noActiveRelays)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -153,6 +153,9 @@ struct DatabaseEncryptionView: View {
|
||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||
}
|
||||
try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
|
||||
if currentKey != newKey {
|
||||
shouldDeleteDatabaseBackupsDefault.set(true)
|
||||
}
|
||||
encryptionStartedDefault.set(false)
|
||||
initialRandomDBPassphraseGroupDefault.set(false)
|
||||
if migration {
|
||||
|
||||
@@ -118,6 +118,9 @@ struct DatabaseErrorView: View {
|
||||
case .errorKeychain:
|
||||
titleText("Keychain error")
|
||||
errorView(Text("Cannot access keychain to save database password"))
|
||||
case .errorKeyGeneration:
|
||||
titleText("Database error")
|
||||
errorView(Text("Cannot generate random database passphrase"))
|
||||
case .invalidConfirmation:
|
||||
// this can only happen if incorrect parameter is passed
|
||||
titleText("Invalid migration confirmation")
|
||||
@@ -217,6 +220,8 @@ struct DatabaseErrorView: View {
|
||||
)
|
||||
case .errorKeychain:
|
||||
am.showAlertMsg(title: "Keychain error")
|
||||
case .errorKeyGeneration:
|
||||
am.showAlertMsg(title: "Database error", message: "Cannot generate random database passphrase")
|
||||
case let .errorSQL(_, error):
|
||||
am.showAlert(Alert(
|
||||
title: Text("Database error"),
|
||||
|
||||
@@ -453,6 +453,10 @@ struct DatabaseView: View {
|
||||
ChatReceiver.shared.start()
|
||||
chatLastStartGroupDefault.set(Date.now)
|
||||
AppChatState.shared.set(.active)
|
||||
if shouldDeleteDatabaseBackupsDefault.get() {
|
||||
deleteDatabaseBackups()
|
||||
shouldDeleteDatabaseBackupsDefault.set(false)
|
||||
}
|
||||
} catch let error {
|
||||
runChat.wrappedValue = false
|
||||
showAlert(NSLocalizedString("Error starting chat", comment: ""), message: responseError(error))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -709,6 +709,8 @@ private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert:
|
||||
alert.wrappedValue = .wrongPassphrase()
|
||||
case .errorKeychain:
|
||||
alert.wrappedValue = .keychainError()
|
||||
case .errorKeyGeneration:
|
||||
alert.wrappedValue = .databaseError(message: NSLocalizedString("Cannot generate random database passphrase", comment: "alert message"))
|
||||
case let .errorSQL(_, error):
|
||||
alert.wrappedValue = .databaseError(message: error)
|
||||
case let .unknown(error):
|
||||
|
||||
@@ -539,7 +539,7 @@ struct MigrateToDevice: View {
|
||||
Task {
|
||||
do {
|
||||
if !hasChatCtrl() {
|
||||
chatInitControllerRemovingDatabases()
|
||||
try chatInitControllerRemovingDatabases()
|
||||
} else if ChatModel.shared.chatRunning == true {
|
||||
// cannot delete storage if chat is running
|
||||
try await stopChatAsync()
|
||||
@@ -735,6 +735,8 @@ private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert:
|
||||
alert.wrappedValue = .wrongPassphrase()
|
||||
case .errorKeychain:
|
||||
alert.wrappedValue = .keychainError()
|
||||
case .errorKeyGeneration:
|
||||
alert.wrappedValue = .databaseError(message: NSLocalizedString("Cannot generate random database passphrase", comment: "alert message"))
|
||||
case let .errorSQL(_, error):
|
||||
alert.wrappedValue = .databaseError(message: error)
|
||||
case let .unknown(error):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ let DEFAULT_SHOULD_IMPORT_APP_SETTINGS = "shouldImportAppSettings"
|
||||
let DEFAULT_DEVELOPER_TOOLS = "developerTools"
|
||||
let DEFAULT_ENCRYPTION_STARTED = "encryptionStarted"
|
||||
let DEFAULT_ENCRYPTION_STARTED_AT = "encryptionStartedAt"
|
||||
let DEFAULT_SHOULD_DELETE_DATABASE_BACKUPS = "shouldDeleteDatabaseBackups"
|
||||
let DEFAULT_ACCENT_COLOR_RED = "accentColorRed" // deprecated, only used for migration
|
||||
let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for migration
|
||||
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration
|
||||
@@ -83,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"
|
||||
@@ -144,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: "{}",
|
||||
@@ -207,6 +210,8 @@ let encryptionStartedDefault = BoolDefault(defaults: UserDefaults.standard, forK
|
||||
|
||||
let encryptionStartedAtDefault = DateDefault(defaults: UserDefaults.standard, forKey: DEFAULT_ENCRYPTION_STARTED_AT)
|
||||
|
||||
let shouldDeleteDatabaseBackupsDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOULD_DELETE_DATABASE_BACKUPS)
|
||||
|
||||
let connectViaLinkTabDefault = EnumDefault<ConnectViaLinkTab>(defaults: UserDefaults.standard, forKey: DEFAULT_CONNECT_VIA_LINK_TAB, withDefault: .scan)
|
||||
|
||||
let privacySimplexLinkModeDefault = EnumDefault<SimpleXLinkMode>(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description)
|
||||
|
||||
@@ -10,33 +10,66 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct StorageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State var appGroupFiles: [String: Int64] = [:]
|
||||
@State var documentsFiles: [String: Int64] = [:]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
directoryView("App group:", appGroupFiles)
|
||||
directoryView("App group:", getGroupContainerDirectory(), appGroupFiles)
|
||||
if !documentsFiles.isEmpty {
|
||||
directoryView("Documents:", documentsFiles)
|
||||
directoryView("Documents:", getDocumentsDirectory(), documentsFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.onAppear {
|
||||
appGroupFiles = traverseFiles(in: getGroupContainerDirectory())
|
||||
documentsFiles = traverseFiles(in: getDocumentsDirectory())
|
||||
}
|
||||
.onAppear(perform: loadFiles)
|
||||
}
|
||||
|
||||
private func loadFiles() {
|
||||
appGroupFiles = traverseFiles(in: getGroupContainerDirectory())
|
||||
documentsFiles = traverseFiles(in: getDocumentsDirectory())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View {
|
||||
private func directoryView(_ name: LocalizedStringKey, _ dir: URL, _ contents: [String: Int64]) -> some View {
|
||||
Text(name).font(.headline)
|
||||
ForEach(Array(contents), id: \.key) { (key, value) in
|
||||
Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary)))
|
||||
let sizeText = Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary)))
|
||||
if dir.appendingPathComponent(key).path == getTempFilesDirectory().path {
|
||||
let stopped = m.chatRunning == false
|
||||
HStack {
|
||||
sizeText
|
||||
Button("Delete temp data", role: .destructive, action: confirmDeleteTempFiles)
|
||||
.disabled(!stopped)
|
||||
}
|
||||
if !stopped {
|
||||
Text("Stop chat in Settings → Chat data → Database passphrase & export to delete temp data.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
} else {
|
||||
sizeText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func confirmDeleteTempFiles() {
|
||||
showAlert(
|
||||
NSLocalizedString("Delete temp data?", comment: "alert title"),
|
||||
message: NSLocalizedString("Files being sent or received will never complete. Videos in unsent drafts will be sent without the file.", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Delete", comment: "alert action"), style: .destructive) { _ in
|
||||
deleteTempFiles()
|
||||
loadFiles()
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
}
|
||||
|
||||
private func traverseFiles(in dir: URL) -> [String: Int64] {
|
||||
var res: [String: Int64] = [:]
|
||||
let fm = FileManager.default
|
||||
|
||||
@@ -139,7 +139,7 @@ struct UserAddressView: View {
|
||||
title: Text("Share address with SimpleX contacts?"),
|
||||
message: Text("Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts."),
|
||||
primaryButton: .default(Text("Share")) {
|
||||
setProfileAddress($progressIndicator, true)
|
||||
setProfileAddress($progressIndicator, true) { shareViaProfile = false }
|
||||
shareViaProfile = true
|
||||
}, secondaryButton: .cancel()
|
||||
)
|
||||
@@ -318,7 +318,7 @@ struct UserAddressView: View {
|
||||
alert = .shareOnCreate
|
||||
progressIndicator = false
|
||||
} else {
|
||||
setProfileAddress($progressIndicator, true)
|
||||
setProfileAddress($progressIndicator, true) { shareViaProfile = false }
|
||||
shareViaProfile = true
|
||||
}
|
||||
} else {
|
||||
@@ -601,7 +601,7 @@ struct AddressSettingsState: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
private func setProfileAddress(_ progressIndicator: Binding<Bool>, _ on: Bool) {
|
||||
private func setProfileAddress(_ progressIndicator: Binding<Bool>, _ on: Bool, revert: @escaping () -> Void) {
|
||||
progressIndicator.wrappedValue = true
|
||||
Task {
|
||||
do {
|
||||
@@ -613,7 +613,11 @@ private func setProfileAddress(_ progressIndicator: Binding<Bool>, _ on: Bool) {
|
||||
await MainActor.run { progressIndicator.wrappedValue = false }
|
||||
} catch let error {
|
||||
logger.error("apiSetProfileAddress: \(responseError(error))")
|
||||
await MainActor.run { progressIndicator.wrappedValue = false }
|
||||
await MainActor.run {
|
||||
progressIndicator.wrappedValue = false
|
||||
revert()
|
||||
showErrorAlert(error, NSLocalizedString("Error saving profile", comment: "alert title"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -709,7 +713,10 @@ struct UserAddressSettingsView: View {
|
||||
title: NSLocalizedString("Share", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
setProfileAddress($progressIndicator, on)
|
||||
setProfileAddress($progressIndicator, on) {
|
||||
ignoreShareViaProfileChange = true
|
||||
shareViaProfile = !on
|
||||
}
|
||||
}
|
||||
)
|
||||
]}
|
||||
@@ -731,7 +738,10 @@ struct UserAddressSettingsView: View {
|
||||
title: NSLocalizedString("Stop sharing", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
setProfileAddress($progressIndicator, on)
|
||||
setProfileAddress($progressIndicator, on) {
|
||||
ignoreShareViaProfileChange = true
|
||||
shareViaProfile = !on
|
||||
}
|
||||
}
|
||||
)
|
||||
]}
|
||||
|
||||
@@ -29,14 +29,14 @@ 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 {
|
||||
HStack {
|
||||
TextField("Enter your name…", text: $profile.displayName)
|
||||
.focused($focusDisplayName)
|
||||
if !validDisplayName(profile.displayName) {
|
||||
if !validNewProfileName {
|
||||
Button {
|
||||
alert = .invalidNameError(validName: mkValidName(profile.displayName))
|
||||
} label: {
|
||||
@@ -147,10 +147,15 @@ struct UserProfile: View {
|
||||
(chatModel.currentUser?.profile.description ?? "") != description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
) &&
|
||||
profile.displayName.trimmingCharacters(in: .whitespaces) != "" &&
|
||||
validDisplayName(profile.displayName) &&
|
||||
validNewProfileName &&
|
||||
bioFitsLimit()
|
||||
}
|
||||
|
||||
private var validNewProfileName: Bool {
|
||||
profile.displayName == chatModel.currentUser?.profile.displayName
|
||||
|| validDisplayName(profile.displayName)
|
||||
}
|
||||
|
||||
private func saveProfile() {
|
||||
focusDisplayName = false
|
||||
Task {
|
||||
@@ -216,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 {
|
||||
@@ -232,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -247,6 +247,11 @@ class ShareModel: ObservableObject {
|
||||
title: "Keychain error",
|
||||
message: "Cannot access keychain to save database password"
|
||||
)
|
||||
case .errorKeyGeneration:
|
||||
ErrorAlert(
|
||||
title: "Database error",
|
||||
message: "Cannot generate random database passphrase"
|
||||
)
|
||||
case .invalidConfirmation:
|
||||
ErrorAlert("Invalid migration confirmation")
|
||||
case let .unknown(json):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,8 +166,13 @@
|
||||
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 */; };
|
||||
64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */; };
|
||||
64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */; };
|
||||
647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; };
|
||||
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
|
||||
6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */; };
|
||||
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
|
||||
6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; };
|
||||
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; };
|
||||
@@ -181,20 +186,18 @@
|
||||
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.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a */; };
|
||||
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a */; };
|
||||
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */; };
|
||||
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */; };
|
||||
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
|
||||
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
|
||||
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
|
||||
@@ -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,8 +563,13 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownFileView.swift; sourceTree = "<group>"; };
|
||||
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
|
||||
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = "<group>"; };
|
||||
@@ -575,19 +585,17 @@
|
||||
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.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a"; sourceTree = "<group>"; };
|
||||
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.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>"; };
|
||||
@@ -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.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a in Frameworks */,
|
||||
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a in Frameworks */,
|
||||
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */,
|
||||
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */,
|
||||
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -848,8 +857,8 @@
|
||||
64C829992D54AEEE006B9E89 /* libffi.a */,
|
||||
64C829982D54AEED006B9E89 /* libgmp.a */,
|
||||
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
|
||||
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a */,
|
||||
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a */,
|
||||
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */,
|
||||
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<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 = 360;
|
||||
CURRENT_PROJECT_VERSION = 362;
|
||||
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 = 360;
|
||||
CURRENT_PROJECT_VERSION = 362;
|
||||
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 = 360;
|
||||
CURRENT_PROJECT_VERSION = 362;
|
||||
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 = 360;
|
||||
CURRENT_PROJECT_VERSION = 362;
|
||||
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 = 360;
|
||||
CURRENT_PROJECT_VERSION = 362;
|
||||
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 = 360;
|
||||
CURRENT_PROJECT_VERSION = 362;
|
||||
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 = 360;
|
||||
CURRENT_PROJECT_VERSION = 362;
|
||||
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 = 360;
|
||||
CURRENT_PROJECT_VERSION = 362;
|
||||
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 = 360;
|
||||
CURRENT_PROJECT_VERSION = 362;
|
||||
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 = 360;
|
||||
CURRENT_PROJECT_VERSION = 362;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
|
||||
@@ -32,7 +32,12 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
|
||||
} else if useKeychain {
|
||||
if !hasDatabase() {
|
||||
logger.debug("chatMigrateInit generating a random DB key")
|
||||
dbKey = randomDatabasePassword()
|
||||
guard let key = randomDatabasePassword() else {
|
||||
let result = (false, DBMigrationResult.errorKeyGeneration)
|
||||
migrationResult = result
|
||||
return result
|
||||
}
|
||||
dbKey = key
|
||||
initialRandomDBPassphraseGroupDefault.set(true)
|
||||
} else if let key = kcDatabasePassword.get() {
|
||||
dbKey = key
|
||||
@@ -56,7 +61,7 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
|
||||
|
||||
public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation: MigrationConfirmation = .error) -> (DBMigrationResult, chat_ctrl?) {
|
||||
let dbPath = url.path
|
||||
let dbKey = key ?? randomDatabasePassword()
|
||||
guard let dbKey = key ?? randomDatabasePassword() else { return (.errorKeyGeneration, nil) }
|
||||
logger.debug("chatInitTemporaryDatabase path: \(dbPath)")
|
||||
var temporaryController: chat_ctrl? = nil
|
||||
var cPath = dbPath.cString(using: .utf8)!
|
||||
@@ -66,14 +71,14 @@ public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation
|
||||
return (dbMigrationResult(dataFromCString(cjson)), temporaryController)
|
||||
}
|
||||
|
||||
public func chatInitControllerRemovingDatabases() {
|
||||
public func chatInitControllerRemovingDatabases() throws {
|
||||
let dbPath = getAppDatabasePath().path
|
||||
let fm = FileManager.default
|
||||
// Remove previous databases, otherwise, can be .errorNotADatabase with nil controller
|
||||
try? fm.removeItem(atPath: dbPath + CHAT_DB)
|
||||
try? fm.removeItem(atPath: dbPath + AGENT_DB)
|
||||
|
||||
let dbKey = randomDatabasePassword()
|
||||
guard let dbKey = randomDatabasePassword() else { throw RuntimeError("Cannot generate random database passphrase") }
|
||||
logger.debug("chatInitControllerRemovingDatabases path: \(dbPath)")
|
||||
var cPath = dbPath.cString(using: .utf8)!
|
||||
var cKey = dbKey.cString(using: .utf8)!
|
||||
@@ -144,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)")
|
||||
}
|
||||
@@ -171,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? {
|
||||
@@ -353,6 +362,7 @@ public enum DBMigrationResult: Decodable, Equatable {
|
||||
case errorMigration(dbFile: String, migrationError: MigrationError)
|
||||
case errorSQL(dbFile: String, migrationSQLError: String)
|
||||
case errorKeychain
|
||||
case errorKeyGeneration
|
||||
case unknown(json: String)
|
||||
}
|
||||
|
||||
|
||||
@@ -598,6 +598,7 @@ public enum RcvSwitchStatus: String, Decodable, Hashable {
|
||||
case sendingQADD = "sending_qadd"
|
||||
case sendingQUSE = "sending_quse"
|
||||
case receivedMessage = "received_message"
|
||||
case receivedQEND = "received_qend"
|
||||
}
|
||||
|
||||
public struct SndQueueInfo: Decodable, Hashable {
|
||||
@@ -826,6 +827,7 @@ public enum ChatErrorType: Decodable, Hashable {
|
||||
|
||||
public enum BadgeRedeemError: Decodable, Hashable {
|
||||
case invalidCode
|
||||
case invalidReceipt
|
||||
case serviceNotConfigured
|
||||
case badgeActive
|
||||
case serviceError(serviceError: BadgeServiceErrorCode)
|
||||
@@ -846,12 +848,14 @@ public enum BadgeServiceErrorCode: Decodable, Hashable {
|
||||
case paymentNotEntitled
|
||||
case paymentPending
|
||||
case providerUnavailable
|
||||
case providerNotConfigured
|
||||
case rateLimited
|
||||
case codeInvalid
|
||||
case codeUsed
|
||||
case codeExpired
|
||||
case receiptInvalid
|
||||
case receiptUsed
|
||||
case testPurchase
|
||||
case internalError
|
||||
case unknown(String)
|
||||
|
||||
@@ -867,11 +871,13 @@ public enum BadgeServiceErrorCode: Decodable, Hashable {
|
||||
case .paymentNotEntitled: "payment_not_entitled"
|
||||
case .paymentPending: "payment_pending"
|
||||
case .providerUnavailable: "provider_unavailable"
|
||||
case .providerNotConfigured: "provider_not_configured"
|
||||
case .rateLimited: "rate_limited"
|
||||
case .codeInvalid: "code_invalid"
|
||||
case .codeUsed: "code_used"
|
||||
case .codeExpired: "code_expired"
|
||||
case .receiptInvalid: "receipt_invalid"
|
||||
case .testPurchase: "test_purchase"
|
||||
case .receiptUsed: "receipt_used"
|
||||
case .internalError: "internal"
|
||||
case let .unknown(s): s
|
||||
@@ -890,11 +896,13 @@ public enum BadgeServiceErrorCode: Decodable, Hashable {
|
||||
case "payment_not_entitled": self = .paymentNotEntitled
|
||||
case "payment_pending": self = .paymentPending
|
||||
case "provider_unavailable": self = .providerUnavailable
|
||||
case "provider_not_configured": self = .providerNotConfigured
|
||||
case "rate_limited": self = .rateLimited
|
||||
case "code_invalid": self = .codeInvalid
|
||||
case "code_used": self = .codeUsed
|
||||
case "code_expired": self = .codeExpired
|
||||
case "receipt_invalid": self = .receiptInvalid
|
||||
case "test_purchase": self = .testPurchase
|
||||
case "receipt_used": self = .receiptUsed
|
||||
case "internal": self = .internalError
|
||||
case let s: self = .unknown(s)
|
||||
@@ -911,8 +919,14 @@ public func badgeServiceErrorText(_ code: BadgeServiceErrorCode) -> String? {
|
||||
case .unsupportedVersion: NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message")
|
||||
case .unknownPurchaseKey: NSLocalizedString("The badge service does not recognize this badge.", comment: "alert message")
|
||||
case .internalError: NSLocalizedString("The badge service reported an internal error.", comment: "alert message")
|
||||
case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .productUnavailable,
|
||||
.paymentNotEntitled, .paymentPending, .providerUnavailable, .receiptInvalid, .receiptUsed, .unknown: nil
|
||||
case .receiptInvalid: NSLocalizedString("The store did not confirm this purchase.", comment: "alert message")
|
||||
case .testPurchase: NSLocalizedString("Sandbox purchases are not credited. Badges can only be bought in the App Store version of the app.", comment: "alert message")
|
||||
case .receiptUsed: NSLocalizedString("This purchase was already credited to a badge on another device or profile.", comment: "alert message")
|
||||
case .paymentPending: NSLocalizedString("The payment has not been confirmed yet.", comment: "alert message")
|
||||
case .providerUnavailable: NSLocalizedString("The store could not be reached.", comment: "alert message")
|
||||
case .providerNotConfigured: NSLocalizedString("Badges cannot be bought in the app yet.", comment: "alert message")
|
||||
case .productUnavailable: NSLocalizedString("This badge is not on sale.", comment: "alert message")
|
||||
case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .paymentNotEntitled, .unknown: nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,12 @@ public struct WebRTCSession: Codable {
|
||||
|
||||
// Spec: spec/services/calls.md#WebRTCExtraInfo
|
||||
public struct WebRTCExtraInfo: Codable {
|
||||
public init(rtcIceCandidates: String) {
|
||||
public init(rtcSession: String?, rtcIceCandidates: String) {
|
||||
self.rtcSession = rtcSession
|
||||
self.rtcIceCandidates = rtcIceCandidates
|
||||
}
|
||||
|
||||
public var rtcSession: String?
|
||||
public var rtcIceCandidates: String
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ public struct Profile: Codable, NamedChat, Hashable {
|
||||
self.image = image
|
||||
self.contactLink = contactLink
|
||||
self.preferences = preferences
|
||||
self.peerType = peerType
|
||||
self.contactDomain = contactDomain
|
||||
}
|
||||
|
||||
@@ -227,6 +228,7 @@ public enum ChatPeerType: Hashable {
|
||||
case human
|
||||
case bot
|
||||
case business
|
||||
case agent
|
||||
case unknown(String)
|
||||
|
||||
public var text: String {
|
||||
@@ -234,6 +236,7 @@ public enum ChatPeerType: Hashable {
|
||||
case .human: "human"
|
||||
case .bot: "bot"
|
||||
case .business: "business"
|
||||
case .agent: "agent"
|
||||
case let .unknown(s): s
|
||||
}
|
||||
}
|
||||
@@ -245,6 +248,7 @@ extension ChatPeerType: Codable {
|
||||
case "human": self = .human
|
||||
case "bot": self = .bot
|
||||
case "business": self = .business
|
||||
case "agent": self = .agent
|
||||
case let s: self = .unknown(s)
|
||||
}
|
||||
}
|
||||
@@ -255,6 +259,14 @@ extension ChatPeerType: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public func peerTypeIcon(_ peerType: ChatPeerType?) -> Image {
|
||||
switch peerType {
|
||||
case .bot: Image(systemName: "cube.fill")
|
||||
case .agent: Image("agent")
|
||||
default: Image(systemName: "person.crop.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status.
|
||||
// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter.
|
||||
public enum BadgeType: Hashable {
|
||||
@@ -368,6 +380,24 @@ public enum BadgeIssueFailure: Decodable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
public var purchaseText: String {
|
||||
switch self {
|
||||
case let .serviceError(code, _):
|
||||
badgeServiceErrorText(code)
|
||||
?? String.localizedStringWithFormat(NSLocalizedString("The badge service refused the purchase: %@", comment: "badge purchase error"), code.text)
|
||||
case .invalidCredential: NSLocalizedString("This app version cannot verify this badge. Please update the app.", comment: "alert message")
|
||||
case .serviceTimeout, .network, .unexpected: text
|
||||
}
|
||||
}
|
||||
|
||||
public var purchaseAlertTitle: String {
|
||||
switch self {
|
||||
case .serviceError(.testPurchase, _):
|
||||
NSLocalizedString("Sandbox purchase", comment: "alert title")
|
||||
default: NSLocalizedString("Purchase error", comment: "alert title")
|
||||
}
|
||||
}
|
||||
|
||||
// the stored form, for support
|
||||
public var tag: String {
|
||||
switch self {
|
||||
@@ -583,7 +613,8 @@ public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias
|
||||
contactLink: profile.contactLink,
|
||||
preferences: profile.preferences,
|
||||
peerType: profile.peerType,
|
||||
localAlias: localAlias
|
||||
localAlias: localAlias,
|
||||
contactDomain: profile.contactDomain
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2512,8 +2543,8 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
|
||||
activeConn?.customUserProfileId != nil
|
||||
}
|
||||
|
||||
public var chatIconName: String {
|
||||
isBot ? "cube.fill" : "person.crop.circle.fill"
|
||||
public var chatIcon: Image {
|
||||
peerTypeIcon(profile.peerType)
|
||||
}
|
||||
|
||||
public func allowsFeature(_ feature: ChatFeature) -> Bool {
|
||||
@@ -3253,7 +3284,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
|
||||
public var relayLink: String?
|
||||
public var memberVerifiedCode: SecurityCode?
|
||||
|
||||
public var id: String { "#\(groupId) @\(groupMemberId)" }
|
||||
public var id: String { groupMemberEntityId(groupId, groupMemberId) }
|
||||
public var ready: Bool { get { activeConn?.connStatus == .ready } }
|
||||
public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } }
|
||||
public var sendMsgEnabled: Bool { get {
|
||||
@@ -3442,6 +3473,10 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
|
||||
)
|
||||
}
|
||||
|
||||
public func groupMemberEntityId(_ groupId: Int64, _ groupMemberId: Int64) -> String {
|
||||
"#\(groupId) @\(groupMemberId)"
|
||||
}
|
||||
|
||||
public struct GroupSupportChat: Codable, Hashable {
|
||||
public var chatTs: Date
|
||||
public var unread: Int
|
||||
@@ -3758,11 +3793,12 @@ public struct CIReaction: Decodable, Hashable {
|
||||
}
|
||||
|
||||
public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||
public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) {
|
||||
public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, markdownBlocks: [MarkdownBlock]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) {
|
||||
self.chatDir = chatDir
|
||||
self.meta = meta
|
||||
self.content = content
|
||||
self.formattedText = formattedText
|
||||
self.markdownBlocks = markdownBlocks
|
||||
self.mentions = mentions
|
||||
self.quotedItem = quotedItem
|
||||
self.reactions = reactions
|
||||
@@ -3773,6 +3809,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||
public var meta: CIMeta
|
||||
public var content: CIContent
|
||||
public var formattedText: [FormattedText]?
|
||||
public var markdownBlocks: [MarkdownBlock]?
|
||||
public var mentions: [String: CIMention]?
|
||||
public var quotedItem: CIQuote?
|
||||
public var reactions: [CIReactionCount]
|
||||
@@ -3782,7 +3819,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
|
||||
public var isLiveDummy: Bool = false
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file
|
||||
case chatDir, meta, content, formattedText, markdownBlocks, mentions, quotedItem, reactions, file
|
||||
}
|
||||
|
||||
public var id: Int64 { meta.itemId }
|
||||
@@ -4215,6 +4252,10 @@ public enum CIDirection: Decodable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
public var groupMemberId: Int64? {
|
||||
if case let .groupRcv(groupMember) = self { groupMember.groupMemberId } else { nil }
|
||||
}
|
||||
|
||||
public func sameDirection(_ dir: CIDirection) -> Bool {
|
||||
switch (self, dir) {
|
||||
case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId
|
||||
@@ -4240,6 +4281,7 @@ public struct CIMeta: Decodable, Hashable {
|
||||
public var userMention: Bool
|
||||
public var deletable: Bool
|
||||
public var editable: Bool
|
||||
public var forwardedByMember: Int64?
|
||||
public var showGroupAsSender: Bool
|
||||
public var msgVerified: MsgVerified?
|
||||
|
||||
@@ -4819,6 +4861,7 @@ public struct CIQuote: Decodable, ItemContent, Hashable {
|
||||
public var sentAt: Date
|
||||
public var content: MsgContent
|
||||
public var formattedText: [FormattedText]?
|
||||
public var markdownBlocks: [MarkdownBlock]?
|
||||
public var text: String {
|
||||
switch (content.text, content) {
|
||||
case let ("", .voice(_, duration)): return durationText(duration)
|
||||
@@ -5632,14 +5675,119 @@ public struct FormattedText: Decodable, Hashable {
|
||||
public var linkUri: String? {
|
||||
switch format {
|
||||
case .uri: text
|
||||
case let .hyperLink(_, linkUri): linkUri
|
||||
case let .hyperLink(_, linkUri): isEmailLink ? nil : linkUri
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
public var isEmailLink: Bool {
|
||||
if case let .hyperLink(_, linkUri) = format { linkUri.hasPrefix("mailto:") } else { false }
|
||||
}
|
||||
}
|
||||
|
||||
public enum BlockToken: Decodable, Hashable {
|
||||
case lineBreak
|
||||
case softBreak
|
||||
case hardBreak
|
||||
case paragraphBreak(lineBreaks: Int)
|
||||
case indent
|
||||
case headingMark(level: Int)
|
||||
case headingClose
|
||||
case headingUnderline(level: Int)
|
||||
case listMark(number: String?)
|
||||
case quoteMark
|
||||
case fenceMark(language: String?)
|
||||
case rowMark
|
||||
case cellMark
|
||||
case delimiterRow(columns: [TableAlign])
|
||||
case ruleMark
|
||||
|
||||
public var drawnText: String {
|
||||
switch self {
|
||||
case .softBreak: " "
|
||||
case .lineBreak, .hardBreak: "\n"
|
||||
case let .paragraphBreak(lineBreaks): String(repeating: "\n", count: lineBreaks)
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
|
||||
public var previewText: String {
|
||||
switch self {
|
||||
case .softBreak, .hardBreak, .cellMark: " "
|
||||
case .lineBreak, .paragraphBreak: "\n"
|
||||
case let .listMark(number): (number ?? "•") + " "
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum MarkdownBlock: Decodable, Hashable {
|
||||
case paragraph(content: SegmentRange)
|
||||
case heading(level: Int, content: SegmentRange)
|
||||
case codeBlock(language: String?, content: SegmentRange)
|
||||
case quote(content: SegmentRange)
|
||||
case list(items: [ListEntry])
|
||||
case table(columns: [TableAlign], header: [SegmentRange], rows: [[SegmentRange]])
|
||||
case rule(mark: SegmentRange)
|
||||
}
|
||||
|
||||
public struct ListEntry: Decodable, Hashable {
|
||||
public var number: String?
|
||||
public var content: SegmentRange
|
||||
public var items: [ListEntry]
|
||||
}
|
||||
|
||||
public struct SegmentRange: Decodable, Hashable {
|
||||
public var from: Int
|
||||
public var to: Int
|
||||
}
|
||||
|
||||
public enum TableAlign: String, Decodable, Hashable {
|
||||
case none
|
||||
case left
|
||||
case center
|
||||
case right
|
||||
}
|
||||
|
||||
public struct BlockTextRange {
|
||||
public var range: SegmentRange
|
||||
public var inRow: Bool
|
||||
}
|
||||
|
||||
extension MarkdownBlock {
|
||||
public var textRanges: [BlockTextRange] {
|
||||
switch self {
|
||||
case let .paragraph(content), let .heading(_, content), let .codeBlock(_, content), let .quote(content):
|
||||
[BlockTextRange(range: content, inRow: false)]
|
||||
case let .list(items):
|
||||
listEntries(items, 0).map { BlockTextRange(range: $0.entry.content, inRow: false) }
|
||||
case let .table(_, header, rows):
|
||||
([header] + rows).flatMap { row in row.enumerated().map { c, cell in BlockTextRange(range: cell, inRow: c > 0) } }
|
||||
case .rule:
|
||||
[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func listEntries(_ items: [ListEntry], _ level: Int) -> [(entry: ListEntry, level: Int)] {
|
||||
items.flatMap { [(entry: $0, level: level)] + listEntries($0.items, level + 1) }
|
||||
}
|
||||
|
||||
public func blockTextRanges(_ blocks: [MarkdownBlock]) -> [BlockTextRange] {
|
||||
blocks.flatMap { $0.textRanges }
|
||||
}
|
||||
|
||||
public func headingSegments(_ size: Int, _ blocks: [MarkdownBlock]) -> [Bool] {
|
||||
var bold = [Bool](repeating: false, count: size)
|
||||
for case let .heading(_, content) in blocks {
|
||||
let from = min(max(content.from, 0), size)
|
||||
for i in from..<max(from, min(content.to, size)) { bold[i] = true }
|
||||
}
|
||||
return bold
|
||||
}
|
||||
|
||||
public enum Format: Decodable, Equatable, Hashable {
|
||||
case bold
|
||||
case bold(singleStar: Bool)
|
||||
case italic
|
||||
case strikeThrough
|
||||
case snippet
|
||||
@@ -5655,6 +5803,8 @@ public enum Format: Decodable, Equatable, Hashable {
|
||||
case modal(modalName: String, text: String)
|
||||
case email
|
||||
case phone
|
||||
case escape
|
||||
case markup(token: BlockToken)
|
||||
case unknown
|
||||
|
||||
// client-only format that opens a modal when tapped, see openMarkdownModal
|
||||
@@ -6358,6 +6508,7 @@ public struct ChatItemVersion: Decodable, Hashable {
|
||||
public var chatItemVersionId: Int64
|
||||
public var msgContent: MsgContent
|
||||
public var formattedText: [FormattedText]?
|
||||
public var markdownBlocks: [MarkdownBlock]?
|
||||
public var itemVersionTs: Date
|
||||
public var createdAt: Date
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public protocol ChatLike {
|
||||
var chatInfo: ChatInfo { get }
|
||||
@@ -91,13 +92,13 @@ private func canForwardToChat(_ cInfo: ChatInfo) -> Bool {
|
||||
}
|
||||
}
|
||||
|
||||
public func chatIconName(_ cInfo: ChatInfo) -> String {
|
||||
public func chatIcon(_ cInfo: ChatInfo) -> Image {
|
||||
switch cInfo {
|
||||
case let .direct(contact): contact.chatIconName
|
||||
case let .group(groupInfo, _): groupInfo.chatIconName
|
||||
case .local: "folder.circle.fill"
|
||||
case .contactRequest: "person.crop.circle.fill"
|
||||
default: "circle.fill"
|
||||
case let .direct(contact): contact.chatIcon
|
||||
case let .group(groupInfo, _): Image(systemName: groupInfo.chatIconName)
|
||||
case .local: Image(systemName: "folder.circle.fill")
|
||||
case .contactRequest: Image(systemName: "person.crop.circle.fill")
|
||||
default: Image(systemName: "circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,16 @@ public func deleteAppFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteTempFiles() {
|
||||
let fm = FileManager.default
|
||||
do {
|
||||
try fm.removeItem(at: getTempFilesDirectory())
|
||||
try fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true)
|
||||
} catch {
|
||||
logger.error("FileUtils deleteTempFiles error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
public func fileSize(_ url: URL) -> Int? { // in bytes
|
||||
do {
|
||||
let val = try url.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey])
|
||||
@@ -194,6 +204,13 @@ private func restoreBackupFile(fromPath: String, toPath: String) throws {
|
||||
try fm.copyItem(atPath: fromPath, toPath: toPath)
|
||||
}
|
||||
|
||||
public func deleteDatabaseBackups() {
|
||||
let fm = FileManager.default
|
||||
let dbPath = getAppDatabasePath().path
|
||||
try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK)
|
||||
try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK)
|
||||
}
|
||||
|
||||
public func hasLegacyDatabase() -> Bool {
|
||||
hasDatabaseAtPath(getLegacyDatabasePath())
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public struct KeyChainItem {
|
||||
}
|
||||
}
|
||||
|
||||
func randomDatabasePassword() -> String {
|
||||
func randomDatabasePassword() -> String? {
|
||||
var keyData = Data(count: 32)
|
||||
let status = keyData.withUnsafeMutableBytes {
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!)
|
||||
@@ -46,7 +46,7 @@ func randomDatabasePassword() -> String {
|
||||
return keyData.base64EncodedString()
|
||||
} else {
|
||||
logger.error("randomDatabasePassword: error \(status)")
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) ->
|
||||
title = NSLocalizedString("Encrypted message: no passphrase", comment: "notification")
|
||||
case .errorMigration:
|
||||
title = NSLocalizedString("Encrypted message: database migration error", comment: "notification")
|
||||
case .errorSQL:
|
||||
case .errorSQL, .errorKeyGeneration:
|
||||
title = NSLocalizedString("Encrypted message: database error", comment: "notification")
|
||||
case .errorKeychain:
|
||||
title = NSLocalizedString("Encrypted message: keychain error", comment: "notification")
|
||||
@@ -201,10 +201,11 @@ func hideSecrets(_ cItem: ChatItem, isChannel: Bool = false) -> String {
|
||||
if let md = cItem.formattedText {
|
||||
var res = ""
|
||||
for ft in md {
|
||||
if case .secret = ft.format {
|
||||
res = res + "..."
|
||||
} else {
|
||||
res = res + ft.text
|
||||
switch ft.format {
|
||||
case .secret: res = res + "..."
|
||||
case let .markup(token): res = res + token.previewText
|
||||
case .escape: ()
|
||||
default: res = res + ft.text
|
||||
}
|
||||
}
|
||||
return res
|
||||
|
||||
@@ -103,6 +103,14 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central
|
||||
5. `CallViewRenderers` manages WebRTC video rendering surfaces.
|
||||
6. Call status updates are sent via `apiCallStatus(contact:status:)`.
|
||||
|
||||
### 6.1 Reconnection
|
||||
|
||||
1. A connected call loses its connection, e.g. when the device switches between Wi-Fi and mobile network.
|
||||
2. The call shows "reconnecting…" and plays the connecting sound. Call duration continues, as this state is not reported to the core.
|
||||
3. The callee (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer via `apiSendCallExtraInfo` with `rtcSession`; the caller answers the same way.
|
||||
4. When the connection is restored, the call returns to connected state.
|
||||
5. If the connection is not restored in 30 seconds, the call ends. Clients without ICE restart support ignore the restart offer, so the call ends in this case.
|
||||
|
||||
### 7. Audio Routing
|
||||
|
||||
1. `CallAudioDeviceManager` handles audio device selection.
|
||||
@@ -141,7 +149,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central
|
||||
| `RcvCallInvitation` | `SimpleXChat/CallTypes.swift` | Incoming call: user, contact, callType, sharedKey, callUUID, callTs |
|
||||
| `WebRTCCallOffer` | `SimpleXChat/CallTypes.swift` | SDP offer with call type and WebRTC session data |
|
||||
| `WebRTCSession` | `SimpleXChat/CallTypes.swift` | `rtcSession` (SDP) and `rtcIceCandidates` (serialized) |
|
||||
| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer |
|
||||
| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer, and ICE restart offer/answer |
|
||||
| `WebRTCCallStatus` | `SimpleXChat/CallTypes.swift` | Call lifecycle states for status reporting |
|
||||
| `CallMediaSource` | `SimpleXChat/CallTypes.swift` | `.mic`, `.camera`, `.screenAudio`, `.screenVideo`, `.unknown` |
|
||||
| `VideoCamera` | `SimpleXChat/CallTypes.swift` | `.user` (front) or `.environment` (rear) camera |
|
||||
@@ -152,7 +160,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central
|
||||
|-------|-------|----------|
|
||||
| Chat not ready on CallKit answer | App suspended, slow startup | `waitUntilChatStarted` with 30s timeout; `action.fail()` on timeout |
|
||||
| Call invitation not found | Race condition between notification and event processing | `justRefreshCallInvitations()` retry |
|
||||
| WebRTC peer connection failure | NAT traversal, network issues | Call ends with error status |
|
||||
| WebRTC peer connection failure | NAT traversal, network issues | Before connection: call ends with error status; after connection: reconnection with ICE restart, call ends if not reconnected in 30 seconds |
|
||||
| CallKit action fail | Internal state mismatch | `action.fail()` called, call cleaned up |
|
||||
| No camera/mic permission | User denied permissions | Permission request dialog shown |
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.sw
|
||||
| `apiRejectCall` | `contact` | Reject incoming call | [L158](../Shared/Model/AppAPITypes.swift#L158) |
|
||||
| `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L159](../Shared/Model/AppAPITypes.swift#L159) |
|
||||
| `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L160](../Shared/Model/AppAPITypes.swift#L160) |
|
||||
| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L161](../Shared/Model/AppAPITypes.swift#L161) |
|
||||
| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates or ICE restart offer/answer | [L171](../Shared/Model/AppAPITypes.swift#L171) |
|
||||
| `apiEndCall` | `contact` | End active call | [L162](../Shared/Model/AppAPITypes.swift#L162) |
|
||||
| `apiGetCallInvitations` | -- | Get pending call invitations | [L163](../Shared/Model/AppAPITypes.swift#L163) |
|
||||
| `apiCallStatus` | `contact, callStatus` | Report call status change | [L164](../Shared/Model/AppAPITypes.swift#L164) |
|
||||
|
||||
@@ -189,7 +189,7 @@ Renders emoji-only messages (messages containing only emoji characters) in a lar
|
||||
|
||||
**File**: [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift)
|
||||
|
||||
Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions).
|
||||
Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). Blocks of `markdownBlocks` (headings, lists, quotes, code blocks, tables, rules) are drawn as a column of views by `MarkdownBlocksView`; code blocks and tables scroll sideways.
|
||||
|
||||
### DeletedItemView / MarkedDeletedItemView
|
||||
|
||||
@@ -238,6 +238,12 @@ Renders voice messages with waveform visualization, play/pause control, and dura
|
||||
|
||||
Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download.
|
||||
|
||||
### MarkdownFileView
|
||||
|
||||
**File**: [`Shared/Views/Chat/ChatItem/MarkdownFileView.swift`](../../Shared/Views/Chat/ChatItem/MarkdownFileView.swift)
|
||||
|
||||
Sheet with a viewer of Markdown files up to `MAX_MARKDOWN_VIEW_SIZE` (1 MB), formatted or as text, with Save. Opened by a tap on the file item, or when reception completes after the tap that started it.
|
||||
|
||||
### [CILinkView](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14)
|
||||
|
||||
**File**: [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift)
|
||||
|
||||
@@ -200,6 +200,8 @@ SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Sh
|
||||
|
||||
URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1181).
|
||||
|
||||
App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectViaUrl_()` branches on that scheme before the path switch, so an app link never reaches `planAndConnect()`, and `openAppLink()` dispatches on its path. A badge link (`simplexchat:/badge/code/<code>`) goes to `openBadgeLink()` in [`BadgesRedeemCodeView.swift`](../../Shared/Views/Badges/BadgesRedeemCodeView.swift), which presents `BadgesRedeemLinkView` as an app sheet. The sheet names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the sheet renders), which core would refuse, the sheet does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or a swipe closes the sheet without cancelling anything; if the redemption then succeeds with the sheet closed or covered, an alert says the badge was added and names the profile, unless the profile is hidden and no longer active, when nothing is shown (`showBadgeAddedAlert()`, which the store lane shares). While the sheet shows a redemption in flight (`isBadgeLinkIssuing()`), `connectViaUrl_()` ignores a further app link; otherwise it dismisses the sheets as for any link, which replaces an earlier badge link sheet. Any other path is a link type from a later version, and gets an alert asking to check for app updates. An app link can carry a secret, so `onOpenURL` logs it as "app link" rather than the URL.
|
||||
|
||||
### Call Deep Link
|
||||
|
||||
Call invitations from notifications:
|
||||
|
||||
@@ -134,6 +134,7 @@ Migration results are decoded in Swift as `DBMigrationResult`:
|
||||
- `.errorMigration(dbFile:, migrationError:)` -- migration failed
|
||||
- `.errorSQL(dbFile:, migrationSQLError:)` -- SQL error during migration
|
||||
- `.errorKeychain` -- keychain access failed
|
||||
- `.errorKeyGeneration` -- random database key generation failed
|
||||
- `.unknown(json:)` -- unrecognized response
|
||||
|
||||
---
|
||||
|
||||
@@ -116,7 +116,10 @@ Manages the WebRTC peer connection, media streams, and data channels.
|
||||
| [`sendCallCommand`](../../Shared/Views/Call/WebRTCClient.swift#L176) | Dispatches WCallCommand (offer/answer/ICE) | L176 |
|
||||
| [`addIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L165) | `peerConnection.add(RTCIceCandidate)` | L165 |
|
||||
| [`getInitialIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L285) | Collects initial ICE candidates | L285 |
|
||||
| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L305) | Sends gathered ICE candidates | L305 |
|
||||
| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L409) | Sends gathered ICE candidates | L409 |
|
||||
| [`startReconnecting`](../../Shared/Views/Call/WebRTCClient.swift#L313) | Reports "reconnecting", starts reconnection timeout and, for the party that sent the initial offer, periodic ICE restarts | L313 |
|
||||
| [`restartIce`](../../Shared/Views/Call/WebRTCClient.swift#L339) | Creates ICE restart offer and sends it with candidates in `.ice(iceCandidates:session:)` | L339 |
|
||||
| [`receiveRestartSession`](../../Shared/Views/Call/WebRTCClient.swift#L350) | Applies ICE restart offer (and sends the answer) or answer received in `.ice` command | L350 |
|
||||
| [`enableMedia`](../../Shared/Views/Call/WebRTCClient.swift#L365) | Enable/disable audio or video track | L365 |
|
||||
| [`setupLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L423) | Creates audio/video tracks and adds to connection | L423 |
|
||||
| [`startCaptureLocalVideo`](../../Shared/Views/Call/WebRTCClient.swift#L581) | Front/back camera toggle and capture start | L581 |
|
||||
@@ -179,6 +182,10 @@ All call signaling travels through the same encrypted SMP message channels used
|
||||
5. P2P connection established
|
||||
```
|
||||
|
||||
### Reconnection
|
||||
|
||||
When a connected call loses ICE connection (`.disconnected` or `.failed`), `WebRTCClient` reports `"reconnecting"` connection state instead of ending the call. `ActiveCallView` sets `.reconnecting` call state and plays the connecting sound; this state is not sent to the core via `apiCallStatus`, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds: the restart offer and answer are sent via `apiSendCallExtraInfo` with `rtcSession` (`x.call.extra`). When ICE connects again, the call returns to `.connected`. If it does not reconnect in 30 seconds, `WebRTCClient` reports `"disconnected"` and ends the call.
|
||||
|
||||
### API Commands
|
||||
|
||||
| Command | Direction | Purpose |
|
||||
@@ -187,7 +194,7 @@ All call signaling travels through the same encrypted SMP message channels used
|
||||
| `apiRejectCall(contact:)` | Callee -> Caller | Reject call |
|
||||
| `apiSendCallOffer(contact:, callOffer:)` | Callee -> Caller | Send SDP offer |
|
||||
| `apiSendCallAnswer(contact:, answer:)` | Caller -> Callee | Send SDP answer |
|
||||
| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates |
|
||||
| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates, or ICE restart offer/answer in `rtcSession` |
|
||||
| `apiEndCall(contact:)` | Either | End call |
|
||||
| `apiGetCallInvitations` | -- | Get pending invitations |
|
||||
| `apiCallStatus(contact:, callStatus:)` | -- | Report status change |
|
||||
@@ -317,10 +324,11 @@ struct WebRTCSession {
|
||||
}
|
||||
```
|
||||
|
||||
### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L42)
|
||||
### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L45)
|
||||
|
||||
```swift
|
||||
struct WebRTCExtraInfo {
|
||||
var rtcSession: String? // ICE restart offer or answer
|
||||
var rtcIceCandidates: String // Additional ICE candidates
|
||||
}
|
||||
```
|
||||
@@ -330,7 +338,7 @@ struct WebRTCExtraInfo {
|
||||
Stored in `ChatModel.activeCall`:
|
||||
- Contact reference
|
||||
- Call UUID
|
||||
- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, etc.)
|
||||
- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, `.reconnecting`, etc.)
|
||||
- Media type
|
||||
- WebRTCClient reference
|
||||
|
||||
|
||||
@@ -285,9 +285,13 @@ desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file)
|
||||
|
||||
| Source Location | Spec Document | Product Document |
|
||||
|----------------|---------------|-----------------|
|
||||
| desktop/.../desktop/Main.kt | spec/architecture.md | product/flows/onboarding.md |
|
||||
| desktop/.../desktop/Main.kt | spec/architecture.md, spec/client/navigation.md | product/flows/onboarding.md |
|
||||
| common/.../common/DesktopApp.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md |
|
||||
| common/.../common/StoreWindowState.kt (desktopMain) | spec/architecture.md | product/views/settings.md |
|
||||
| common/.../common/AppLinks.kt (desktopMain) | spec/client/navigation.md | product/views/settings.md |
|
||||
| common/.../common/SingleInstance.kt (desktopMain) | spec/architecture.md, spec/client/navigation.md | product/flows/onboarding.md |
|
||||
| common/.../common/WindowActivation.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md |
|
||||
| common/.../common/platform/AppLinkScheme.desktop.kt (desktopMain) | spec/client/navigation.md | product/views/settings.md |
|
||||
| common/.../common/model/NtfManager.desktop.kt (desktopMain) | spec/services/notifications.md | product/flows/messaging.md |
|
||||
| common/.../common/views/helpers/AppUpdater.kt (desktopMain) | spec/architecture.md | product/views/settings.md |
|
||||
| common/.../common/platform/AnimatedImage.desktop.kt (desktopMain) | spec/client/chat-view.md | product/views/chat.md |
|
||||
|
||||
@@ -101,7 +101,7 @@ Tests are in:
|
||||
|
||||
## Resources & Localization
|
||||
|
||||
- String resources: `common/src/commonMain/resources/MR/base/strings.xml` + 21 language variants
|
||||
- String resources: `common/src/commonMain/moko-resources/base/strings.xml` + 21 language variants
|
||||
- Uses Moko Resources (`dev.icerock.moko:resources`) for cross-platform resource management
|
||||
- The `adjustFormatting` gradle task validates string resources during build
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "chat.simplex.app"
|
||||
namespace = "chat.simplex.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
targetSdk = 36
|
||||
// !!!
|
||||
// skip version code after release to F-Droid, as it uses two version codes
|
||||
versionCode = (extra["android.version_code"] as String).toInt()
|
||||
|
||||
@@ -5,6 +5,7 @@ import chat.simplex.common.views.badges.BadgeProduct
|
||||
import chat.simplex.common.views.badges.BadgePurchaseOutcome
|
||||
import chat.simplex.common.views.badges.BadgeStoreError
|
||||
import chat.simplex.common.views.badges.BadgeStoreProductId
|
||||
import chat.simplex.common.views.badges.BadgeStoreReceipt
|
||||
|
||||
// Play Billing is only in the google flavor, so the Play country stays unknown here
|
||||
fun loadPlayStoreCountry() {}
|
||||
@@ -22,3 +23,11 @@ suspend fun loadBadgeProducts(oneTimeIds: List<BadgeStoreProductId>, subscriptio
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome =
|
||||
throw BadgeStoreError.StoreUnavailable
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {}
|
||||
|
||||
suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> = emptyList()
|
||||
@@ -5,6 +5,7 @@ import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.platform.androidPlayStoreCountry
|
||||
import chat.simplex.common.platform.mainActivity
|
||||
import chat.simplex.common.views.badges.*
|
||||
import chat.simplex.common.views.helpers.withLongRunningApi
|
||||
import com.android.billingclient.api.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -40,7 +41,7 @@ fun loadPlayStoreCountry() {
|
||||
|
||||
// One long-lived client for badges: ProductDetails obtained from it are passed back to it when the
|
||||
// purchase is launched, and the purchase result arrives on its listener rather than as a return value.
|
||||
// volatile: the listener is called on the main thread, the purchase runs on a background dispatcher
|
||||
// volatile: written by the purchase on a background dispatcher, read by the listener on another thread
|
||||
@Volatile private var badgeBillingClient: BillingClient? = null
|
||||
@Volatile private var badgeOffers: Map<BadgeStoreProductId, BadgeOffer> = emptyMap()
|
||||
@Volatile private var badgePurchase: CompletableDeferred<BadgePurchaseOutcome>? = null
|
||||
@@ -80,8 +81,7 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc
|
||||
val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable
|
||||
val client = connectedBadgeBillingClient()
|
||||
val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId)
|
||||
val details = offer.details
|
||||
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details)
|
||||
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(offer.details)
|
||||
offer.offerToken?.let { productParams.setOfferToken(it) }
|
||||
val params = BillingFlowParams.newBuilder()
|
||||
.setProductDetailsParamsList(listOf(productParams.build()))
|
||||
@@ -94,19 +94,42 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc
|
||||
if (launched.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage)
|
||||
}
|
||||
val outcome = purchase.await()
|
||||
if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt)
|
||||
return outcome
|
||||
return purchase.await()
|
||||
} finally {
|
||||
badgePurchase = null
|
||||
}
|
||||
}
|
||||
|
||||
// one-time purchases Play still holds: bought and not consumed, or awaiting a slow payment
|
||||
suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> {
|
||||
val client = connectedBadgeBillingClient()
|
||||
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build()
|
||||
val queried = CompletableDeferred<List<Purchase>>()
|
||||
client.queryPurchasesAsync(params) { result, purchases ->
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
queried.complete(purchases)
|
||||
} else {
|
||||
queried.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage))
|
||||
}
|
||||
}
|
||||
return queried.await().mapNotNull(::badgePurchaseOutcome)
|
||||
}
|
||||
|
||||
private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases ->
|
||||
val pending = badgePurchase ?: return@PurchasesUpdatedListener
|
||||
val pending = badgePurchase
|
||||
if (pending == null) {
|
||||
// settled outside a purchase call, such as a slow payment completing
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
|
||||
withLongRunningApi { purchases.mapNotNull(::badgePurchaseOutcome).forEach { BadgeStore.reconcile(it) } }
|
||||
}
|
||||
return@PurchasesUpdatedListener
|
||||
}
|
||||
when {
|
||||
result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null ->
|
||||
pending.complete(badgePurchaseOutcome(purchases))
|
||||
result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> {
|
||||
val outcomes = purchases.mapNotNull(::badgePurchaseOutcome)
|
||||
pending.complete(outcomes.firstOrNull() ?: BadgePurchaseOutcome.Cancelled)
|
||||
withLongRunningApi { outcomes.drop(1).forEach { BadgeStore.reconcile(it) } }
|
||||
}
|
||||
result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED ->
|
||||
pending.complete(BadgePurchaseOutcome.Cancelled)
|
||||
else ->
|
||||
@@ -114,17 +137,17 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p
|
||||
}
|
||||
}
|
||||
|
||||
private fun badgePurchaseOutcome(purchases: List<Purchase>): BadgePurchaseOutcome {
|
||||
val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled
|
||||
if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending
|
||||
return BadgePurchaseOutcome.Purchased(
|
||||
private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = when (purchase.purchaseState) {
|
||||
Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending(purchase.accountIdentifiers?.obfuscatedAccountId)
|
||||
Purchase.PurchaseState.PURCHASED -> BadgePurchaseOutcome.Purchased(
|
||||
BadgeStoreReceipt(
|
||||
token = purchase.purchaseToken,
|
||||
productId = purchase.products.firstOrNull() ?: "",
|
||||
orderId = purchase.orderId,
|
||||
invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId
|
||||
invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId,
|
||||
acknowledged = purchase.isAcknowledged
|
||||
)
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private suspend fun connectedBadgeBillingClient(): BillingClient {
|
||||
@@ -203,21 +226,34 @@ private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? {
|
||||
return BadgeOffer(id, product, this, offer.offerToken)
|
||||
}
|
||||
|
||||
// nothing is delivered in this build, so the purchase is finished right away; once the service issues
|
||||
// credentials it must only be finished after the credential is stored. One-time products are consumed
|
||||
// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged
|
||||
// purchase after 3 days.
|
||||
private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) {
|
||||
val done = CompletableDeferred<BillingResult>()
|
||||
if (details.productType == BillingClient.ProductType.SUBS) {
|
||||
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build()
|
||||
client.acknowledgePurchase(params) { done.complete(it) }
|
||||
} else {
|
||||
// acknowledged without consuming, so Play stops its 3-day refund clock and still lists the purchase until it is finished
|
||||
suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {
|
||||
if (!receipt.acknowledged) acknowledge(connectedBadgeBillingClient(), receipt.token)
|
||||
}
|
||||
|
||||
// consumed if one-time so it can be bought again, else acknowledged, as Play refunds an unacknowledged purchase
|
||||
// after 3 days; decided by product id, as after a restart there is no ProductDetails for the purchase
|
||||
suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {
|
||||
val client = connectedBadgeBillingClient()
|
||||
if (receipt.productId in badgeOneTimeProductIds) {
|
||||
val done = CompletableDeferred<BillingResult>()
|
||||
val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build()
|
||||
client.consumeAsync(params) { result, _ -> done.complete(result) }
|
||||
}
|
||||
val result = done.await()
|
||||
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}")
|
||||
checkBillingResult(done.await())
|
||||
} else {
|
||||
acknowledge(client, receipt.token)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun acknowledge(client: BillingClient, token: String) {
|
||||
val done = CompletableDeferred<BillingResult>()
|
||||
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(token).build()
|
||||
client.acknowledgePurchase(params) { done.complete(it) }
|
||||
checkBillingResult(done.await())
|
||||
}
|
||||
|
||||
private fun checkBillingResult(result: BillingResult) {
|
||||
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<!-- enableOnBackInvokedCallback="false": Activity.onBackPressed() overrides are not called on Android 16+ without it.
|
||||
UnusedAttribute is ignored because the attribute only exists on API 33+, older versions call onBackPressed() anyway -->
|
||||
<application
|
||||
android:name="SimplexApp"
|
||||
android:allowBackup="false"
|
||||
@@ -44,7 +46,9 @@
|
||||
android:largeHeap="true"
|
||||
android:extractNativeLibs="${extract_native_libs}"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SimpleX">
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:theme="@style/Theme.SimpleX"
|
||||
tools:ignore="UnusedAttribute">
|
||||
<!-- android:localeConfig="@xml/locales_config"-->
|
||||
|
||||
<!-- Main activity -->
|
||||
@@ -70,6 +74,15 @@
|
||||
|
||||
<data android:scheme="simplex" />
|
||||
</intent-filter>
|
||||
<!-- open simplexchat:/ app URI -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="simplexchat" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import chat.simplex.app.views.call.CallActivity
|
||||
import chat.simplex.common.model.NotificationPreviewMode
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.res.*
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
class CallService: Service() {
|
||||
|
||||
@@ -18,7 +18,7 @@ import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.res.*
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
class MainActivity: FragmentActivity() {
|
||||
|
||||
@@ -28,7 +28,9 @@ import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.badges.BadgeProduct
|
||||
import chat.simplex.common.views.badges.BadgePurchaseOutcome
|
||||
import chat.simplex.common.views.badges.BadgeStore
|
||||
import chat.simplex.common.views.badges.BadgeStoreProductId
|
||||
import chat.simplex.common.views.badges.BadgeStoreReceipt
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.database.deleteOldChatArchive
|
||||
import chat.simplex.common.views.helpers.*
|
||||
@@ -113,6 +115,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
}
|
||||
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
|
||||
}
|
||||
BadgeStore.presentUnfinished()
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
@@ -352,6 +355,12 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
|
||||
override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId)
|
||||
|
||||
override suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) = acknowledgeBadgePurchase(receipt)
|
||||
|
||||
override suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) = finishBadgePurchase(receipt)
|
||||
|
||||
override suspend fun androidUnfinishedBadgePurchases(): List<BadgePurchaseOutcome> = unfinishedBadgePurchases()
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Composable
|
||||
override fun androidLockPortraitOrientation() {
|
||||
|
||||
@@ -29,7 +29,7 @@ import chat.simplex.common.model.NotificationsMode
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import kotlinx.coroutines.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.res.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.call.CallMediaType
|
||||
import chat.simplex.common.views.call.RcvCallInvitation
|
||||
import kotlinx.datetime.Clock
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.res.*
|
||||
|
||||
object NtfManager {
|
||||
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||
|
||||
+6
-2
@@ -36,6 +36,7 @@ import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.model.NtfManager.AcceptCallAction
|
||||
import chat.simplex.common.AppLock
|
||||
import chat.simplex.common.helpers.applyAppLocale
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
@@ -44,7 +45,7 @@ import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.res.*
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -342,7 +343,10 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
||||
chatModel.activeCallInvitation.value = null
|
||||
ntfManager.cancelCallNotification()
|
||||
},
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||
acceptCall = {
|
||||
AppLock.recheckAuthState()
|
||||
cm.acceptIncomingCall(invitation = invitation)
|
||||
},
|
||||
openApp = {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
.setAction(NtfManager.OpenChatAction)
|
||||
|
||||
@@ -48,7 +48,7 @@ buildscript {
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:${rootProject.extra["gradle.plugin.version"]}")
|
||||
classpath(kotlin("gradle-plugin", version = rootProject.extra["kotlin.version"] as String))
|
||||
classpath("dev.icerock.moko:resources-generator:0.23.0")
|
||||
classpath("dev.icerock.moko:resources-generator:0.27.1")
|
||||
|
||||
// Workaround gradle sync issue: https://github.com/gmazzo/gradle-buildconfig-plugin/issues/137#issuecomment-1935739759
|
||||
classpath("com.squareup:kotlinpoet:1.16.0")
|
||||
|
||||
@@ -56,11 +56,6 @@ kotlin {
|
||||
}
|
||||
|
||||
val commonMain by getting {
|
||||
if (hasSimplexAssets) {
|
||||
resources.srcDir(simplexAssetsLocal)
|
||||
} else {
|
||||
resources.srcDir("src/commonMain/resources/assets/default")
|
||||
}
|
||||
dependencies {
|
||||
api(compose.runtime)
|
||||
api(compose.foundation)
|
||||
@@ -78,12 +73,12 @@ kotlin {
|
||||
// Link Previews
|
||||
implementation("org.jsoup:jsoup:1.17.2")
|
||||
// Resources
|
||||
api("dev.icerock.moko:resources:0.23.0")
|
||||
api("dev.icerock.moko:resources-compose:0.23.0")
|
||||
api("dev.icerock.moko:resources:0.27.1")
|
||||
api("dev.icerock.moko:resources-compose:0.27.1")
|
||||
|
||||
// Markdown
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer:0.27.0")
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.27.0")
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer:0.34.0")
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.34.0")
|
||||
}
|
||||
}
|
||||
val commonTest by getting {
|
||||
@@ -95,7 +90,6 @@ kotlin {
|
||||
}
|
||||
}
|
||||
val androidMain by getting {
|
||||
kotlin.srcDir("build/generated/moko/androidMain/src")
|
||||
dependencies {
|
||||
implementation("androidx.activity:activity-compose:1.9.1")
|
||||
val workVersion = "2.9.1"
|
||||
@@ -158,27 +152,20 @@ kotlin {
|
||||
|
||||
android {
|
||||
namespace = "chat.simplex.common"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
testOptions.targetSdk = 34
|
||||
lint.targetSdk = 34
|
||||
val isAndroid = gradle.startParameter.taskNames.find {
|
||||
val lower = it.lowercase()
|
||||
lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install")
|
||||
} != null
|
||||
if (isAndroid) {
|
||||
// This is not needed on Android but can't be moved to desktopMain because MR lib don't support this.
|
||||
// No other ways to exclude a file work, but it's large and should be excluded
|
||||
kotlin.sourceSets["commonMain"].resources.exclude("/MR/fonts/NotoColorEmoji-Regular.ttf")
|
||||
}
|
||||
testOptions.targetSdk = 36
|
||||
lint.targetSdk = 36
|
||||
}
|
||||
|
||||
multiplatformResources {
|
||||
multiplatformResourcesPackage = "chat.simplex.res"
|
||||
// multiplatformResourcesClassName = "MR"
|
||||
resourcesPackage.set("chat.simplex.res")
|
||||
resourcesSourceSets {
|
||||
getByName("commonMain").srcDirs(if (hasSimplexAssets) simplexAssetsLocal.resolve("MR") else file("src/commonMain/resources/assets/default/MR"))
|
||||
}
|
||||
}
|
||||
|
||||
buildConfig {
|
||||
@@ -286,8 +273,8 @@ afterEvaluate {
|
||||
}
|
||||
return this
|
||||
}
|
||||
val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$")
|
||||
val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree
|
||||
val fileRegex = Regex("moko-resources/../strings.xml$|moko-resources/..-.../strings.xml$|moko-resources/..-../strings.xml$|moko-resources/base/strings.xml$")
|
||||
val tree = fileTree("src/commonMain/moko-resources").filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree
|
||||
val baseStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found")
|
||||
val lvStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("lv/strings.xml") } ?: throw Exception("No base/strings.xml found")
|
||||
val treeList = ArrayList(tree.toList())
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import android.provider.Settings
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.res.*
|
||||
|
||||
fun Context.openAppSettingsInSystem() {
|
||||
Intent().apply {
|
||||
|
||||
+3
@@ -25,6 +25,9 @@ val defaultLocale: Locale = Locale.getDefault()
|
||||
|
||||
actual fun isAppVisibleAndFocused(): Boolean = isAppOnForeground
|
||||
|
||||
// the manifest declares the scheme
|
||||
actual fun appLinkSchemeRegistered(): Boolean = true
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
lateinit var androidAppContext: Context
|
||||
var mainActivity: WeakReference<FragmentActivity> = WeakReference(null)
|
||||
|
||||
Loaded 100 of 1159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user